@openchamber/web 1.11.5 → 1.11.7

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 (65) hide show
  1. package/README.md +6 -0
  2. package/bin/cli.js +443 -2
  3. package/dist/assets/{MarkdownRendererImpl-C3-ZpwEx.js → MarkdownRendererImpl-DaF15QNC.js} +1 -1
  4. package/dist/assets/{MultiRunWindow-BDfPzMDy.js → MultiRunWindow-Cl7wS_CB.js} +1 -1
  5. package/dist/assets/{OnboardingScreen-DGgh4IXB.js → OnboardingScreen-DTv6YJI1.js} +2 -2
  6. package/dist/assets/{SettingsWindow-B8QKr5dB.js → SettingsWindow-_c3TTL2z.js} +1 -1
  7. package/dist/assets/{TerminalView-D7IIkSGJ.js → TerminalView-CuXkDROt.js} +4 -4
  8. package/dist/assets/es-CYoUf2D-.js +15 -0
  9. package/dist/assets/{index-DHluop4D.js → index-3WXrN3AX.js} +1 -1
  10. package/dist/assets/index-BREIbhcb.css +1 -0
  11. package/dist/assets/ko-2tM0fIna.js +15 -0
  12. package/dist/assets/main-BF3kWAJ9.js +239 -0
  13. package/dist/assets/{main-VVcyjpiF.js → main-o8ZERrmU.js} +2 -2
  14. package/dist/assets/miniChat-BZQjpK23.js +2 -0
  15. package/dist/assets/{modelPrefsAutoSave-Ctdc3cCY.js → modelPrefsAutoSave-wwnbqBk7.js} +109 -107
  16. package/dist/assets/pl-Dq8uAotM.js +15 -0
  17. package/dist/assets/pt-BR-nh9s9DFT.js +15 -0
  18. package/dist/assets/{renderElectronMiniChatApp-CsddCM3q.js → renderElectronMiniChatApp-C-Ezew9P.js} +2 -2
  19. package/dist/assets/uk-BZtz0wUV.js +15 -0
  20. package/dist/assets/{vendor-.bun-Bum-iBXX.js → vendor-.bun-CV3tusA8.js} +1 -1
  21. package/dist/assets/zh-CN-j_nYMchE.js +15 -0
  22. package/dist/assets/zh-TW-B11UpkDJ.js +15 -0
  23. package/dist/index.html +11 -28
  24. package/dist/mini-chat.html +4 -4
  25. package/package.json +1 -1
  26. package/server/index.js +2 -0
  27. package/server/lib/cloudflare-tunnel.js +3 -5
  28. package/server/lib/fs/routes.js +5 -0
  29. package/server/lib/fs/routes.test.js +61 -1
  30. package/server/lib/git/DOCUMENTATION.md +1 -0
  31. package/server/lib/git/routes.js +82 -1
  32. package/server/lib/git/service.js +338 -19
  33. package/server/lib/git/service.test.js +414 -8
  34. package/server/lib/ngrok-tunnel.js +209 -0
  35. package/server/lib/opencode/core-routes.js +1 -0
  36. package/server/lib/opencode/env-runtime.js +52 -4
  37. package/server/lib/opencode/env-runtime.test.js +82 -6
  38. package/server/lib/opencode/feature-routes-runtime.js +35 -0
  39. package/server/lib/opencode/index.js +19 -0
  40. package/server/lib/opencode/npm-registry.js +157 -0
  41. package/server/lib/opencode/npm-registry.test.js +179 -0
  42. package/server/lib/opencode/openchamber-routes.js +9 -7
  43. package/server/lib/opencode/plugin-routes.js +373 -0
  44. package/server/lib/opencode/plugin-routes.test.js +384 -0
  45. package/server/lib/opencode/plugin-spec.js +107 -0
  46. package/server/lib/opencode/plugin-spec.test.js +154 -0
  47. package/server/lib/opencode/plugins.js +393 -0
  48. package/server/lib/opencode/plugins.test.js +176 -0
  49. package/server/lib/opencode/settings-helpers.js +6 -0
  50. package/server/lib/opencode/settings-helpers.test.js +11 -0
  51. package/server/lib/opencode/settings-runtime.js +39 -1
  52. package/server/lib/opencode/settings-runtime.test.js +39 -0
  53. package/server/lib/skills-catalog/source.js +1 -1
  54. package/server/lib/tunnels/DOCUMENTATION.md +1 -0
  55. package/server/lib/tunnels/providers/ngrok.js +117 -0
  56. package/server/lib/tunnels/types.js +2 -0
  57. package/dist/assets/es-dIVpApmS.js +0 -15
  58. package/dist/assets/index-Bk9IWJe1.css +0 -1
  59. package/dist/assets/ko-Cqf3E9-d.js +0 -15
  60. package/dist/assets/main-D45l3Dxw.js +0 -232
  61. package/dist/assets/miniChat-a9w7WM0c.js +0 -2
  62. package/dist/assets/pl-C577DpsX.js +0 -15
  63. package/dist/assets/pt-BR-BeeF6VlK.js +0 -15
  64. package/dist/assets/uk-CZ7XVz_D.js +0 -15
  65. package/dist/assets/zh-CN-BMSSqdyO.js +0 -15
@@ -0,0 +1,384 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import express from 'express';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import request from 'supertest';
7
+
8
+ import { registerPluginRoutes } from './plugin-routes.js';
9
+
10
+ let projectDir;
11
+ let userConfigPath;
12
+ let rootDir;
13
+ let plugins;
14
+ let refreshOpenCodeAfterConfigChange;
15
+ let app;
16
+ let cleanupPaths;
17
+
18
+ const testUnlessRoot = typeof process.getuid === 'function' && process.getuid() === 0 ? test.skip : test;
19
+
20
+ function readJson(filePath) {
21
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
22
+ }
23
+
24
+ function createApp(overrides = {}) {
25
+ const testApp = express();
26
+ testApp.use(express.json());
27
+ registerPluginRoutes(testApp, {
28
+ resolveOptionalProjectDirectory: async () => ({ directory: projectDir, error: null }),
29
+ refreshOpenCodeAfterConfigChange,
30
+ clientReloadDelayMs: 25,
31
+ listPluginEntries: plugins.listPluginEntries,
32
+ getPluginEntry: plugins.getPluginEntry,
33
+ createPluginEntry: plugins.createPluginEntry,
34
+ updatePluginEntry: plugins.updatePluginEntry,
35
+ deletePluginEntry: plugins.deletePluginEntry,
36
+ listPluginDirFiles: plugins.listPluginDirFiles,
37
+ readPluginDirFile: plugins.readPluginDirFile,
38
+ writePluginDirFile: plugins.writePluginDirFile,
39
+ deletePluginDirFile: plugins.deletePluginDirFile,
40
+ encodePluginId: plugins.encodePluginId,
41
+ decodePluginId: plugins.decodePluginId,
42
+ ...overrides,
43
+ });
44
+ return testApp;
45
+ }
46
+
47
+ function createRegistryApp(getNpmInfo) {
48
+ app = createApp({ getNpmInfo });
49
+ return app;
50
+ }
51
+
52
+ async function createEntry(spec = 'a') {
53
+ return request(app)
54
+ .post('/api/config/plugins/entry')
55
+ .send({ spec, scope: 'user' })
56
+ .expect(200);
57
+ }
58
+
59
+ async function createFile(fileName = 'test.js', content = '//x') {
60
+ return request(app)
61
+ .post('/api/config/plugins/file')
62
+ .send({ fileName, content, scope: 'user' })
63
+ .expect(200);
64
+ }
65
+
66
+ describe('opencode plugin routes', () => {
67
+ beforeAll(async () => {
68
+ rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-plugin-routes-'));
69
+ userConfigPath = path.join(rootDir, 'user-opencode.json');
70
+ process.env.OPENCODE_CONFIG = userConfigPath;
71
+ plugins = await import('./plugins.js');
72
+ });
73
+
74
+ beforeEach(() => {
75
+ projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'));
76
+ fs.rmSync(userConfigPath, { force: true });
77
+ fs.rmSync(path.join(rootDir, 'plugins'), { recursive: true, force: true });
78
+ refreshOpenCodeAfterConfigChange = mock(async () => undefined);
79
+ cleanupPaths = [];
80
+ app = createApp();
81
+ });
82
+
83
+ afterEach(() => {
84
+ for (const target of cleanupPaths) {
85
+ try {
86
+ fs.chmodSync(target, 0o600);
87
+ } catch {
88
+ }
89
+ }
90
+ });
91
+
92
+ afterAll(() => {
93
+ fs.rmSync(rootDir, { recursive: true, force: true });
94
+ delete process.env.OPENCODE_CONFIG;
95
+ });
96
+
97
+ test('GET /api/config/plugins empty returns entries and files arrays', async () => {
98
+ const response = await request(app).get('/api/config/plugins').expect(200);
99
+
100
+ expect(response.body).toEqual({ entries: [], files: [] });
101
+ });
102
+
103
+ test('GET /registry with empty specs returns empty results', async () => {
104
+ const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
105
+ createRegistryApp(getNpmInfo);
106
+
107
+ const response = await request(app).get('/api/config/plugins/registry?specs=').expect(200);
108
+
109
+ expect(response.body).toEqual({ results: [] });
110
+ expect(getNpmInfo).not.toHaveBeenCalled();
111
+ });
112
+
113
+ test('GET /registry reports update for exact npm version behind latest', async () => {
114
+ createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
115
+
116
+ const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);
117
+
118
+ expect(response.body.results[0]).toMatchObject({
119
+ kind: 'npm-ok',
120
+ spec: 'foo@1.0.0',
121
+ name: 'foo',
122
+ currentVersion: '1.0.0',
123
+ latestVersion: '2.0.0',
124
+ hasUpdate: true,
125
+ });
126
+ });
127
+
128
+ test('GET /registry reports no update when exact npm version matches latest', async () => {
129
+ createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
130
+
131
+ const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);
132
+
133
+ expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', hasUpdate: false, latestVersion: '1.0.0', currentVersion: '1.0.0' });
134
+ });
135
+
136
+ test('GET /registry reports missing exact npm version', async () => {
137
+ createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
138
+
139
+ const response = await request(app).get('/api/config/plugins/registry?specs=foo@99.99.99').expect(200);
140
+
141
+ expect(response.body.results[0]).toMatchObject({ kind: 'npm-missing-version', name: 'foo', currentVersion: '99.99.99', latestVersion: '2.0.0' });
142
+ });
143
+
144
+ test('GET /registry reports missing npm package', async () => {
145
+ createRegistryApp(mock(async () => ({ ok: false, status: 404, error: 'Package not found' })));
146
+
147
+ const response = await request(app).get('/api/config/plugins/registry?specs=nonexistent@1.0.0').expect(200);
148
+
149
+ expect(response.body.results[0]).toMatchObject({ kind: 'npm-missing-package', spec: 'nonexistent@1.0.0', name: 'nonexistent', error: 'Package not found' });
150
+ });
151
+
152
+ test('GET /registry reports malformed npm spec', async () => {
153
+ const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
154
+ createRegistryApp(getNpmInfo);
155
+
156
+ const response = await request(app).get('/api/config/plugins/registry?specs=%40%40malformed').expect(200);
157
+
158
+ expect(response.body.results[0]).toEqual({ kind: 'npm-malformed', spec: '@@malformed', error: 'Spec syntax is malformed' });
159
+ expect(getNpmInfo).not.toHaveBeenCalled();
160
+ });
161
+
162
+ test('GET /registry reports existing path plugin ok', async () => {
163
+ createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
164
+ const tmpFile = path.join(fs.mkdtempSync(path.join(rootDir, 'plugin-path-')), 'plugin.js');
165
+ fs.writeFileSync(tmpFile, '// plugin', 'utf8');
166
+
167
+ const response = await request(app).get(`/api/config/plugins/registry?specs=${encodeURIComponent(tmpFile)}`).expect(200);
168
+
169
+ expect(response.body.results[0]).toEqual({ kind: 'path-ok', spec: tmpFile, absolutePath: tmpFile });
170
+ });
171
+
172
+ test('GET /registry reports missing path plugin', async () => {
173
+ createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
174
+
175
+ const response = await request(app).get('/api/config/plugins/registry?specs=%2Fnonexistent%2F__path%2Fxyz.js').expect(200);
176
+
177
+ expect(response.body.results[0]).toEqual({ kind: 'path-missing', spec: '/nonexistent/__path/xyz.js', absolutePath: '/nonexistent/__path/xyz.js' });
178
+ });
179
+
180
+ test('GET /registry treats Windows absolute paths as local paths', async () => {
181
+ const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
182
+ createRegistryApp(getNpmInfo);
183
+
184
+ const windowsPath = 'C:\\Users\\me\\plugin.js';
185
+ const response = await request(app)
186
+ .get(`/api/config/plugins/registry?specs=${encodeURIComponent(windowsPath)}`)
187
+ .expect(200);
188
+
189
+ expect(response.body.results[0]).toEqual({ kind: 'path-missing', spec: windowsPath, absolutePath: windowsPath });
190
+ expect(getNpmInfo).not.toHaveBeenCalled();
191
+ });
192
+
193
+ testUnlessRoot('GET /registry reports unreadable path plugin', async () => {
194
+ createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
195
+ const tmpFile = path.join(fs.mkdtempSync(path.join(rootDir, 'plugin-unreadable-')), 'plugin.js');
196
+ fs.writeFileSync(tmpFile, '// plugin', 'utf8');
197
+ cleanupPaths.push(tmpFile);
198
+ fs.chmodSync(tmpFile, 0);
199
+
200
+ const response = await request(app).get(`/api/config/plugins/registry?specs=${encodeURIComponent(tmpFile)}`).expect(200);
201
+
202
+ expect(response.body.results[0]).toEqual({ kind: 'path-unreadable', spec: tmpFile, absolutePath: tmpFile });
203
+ });
204
+
205
+ test('GET /registry reports npm network failure without failing route', async () => {
206
+ createRegistryApp(mock(async () => ({ ok: false, status: 'network', error: 'socket closed' })));
207
+
208
+ const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);
209
+
210
+ expect(response.body.results[0]).toMatchObject({ kind: 'npm-network', spec: 'foo@1.0.0', error: 'socket closed' });
211
+ });
212
+
213
+ test('GET /registry deduplicates npm package lookups by name', async () => {
214
+ const getNpmInfo = mock(async () => ({ ok: true, latest: '3.0.0', versions: ['1', '2', '3'], distTags: { latest: '3.0.0' } }));
215
+ createRegistryApp(getNpmInfo);
216
+
217
+ await request(app).get('/api/config/plugins/registry?specs=foo@1,foo@2,foo@3').expect(200);
218
+
219
+ expect(getNpmInfo).toHaveBeenCalledTimes(1);
220
+ expect(getNpmInfo).toHaveBeenCalledWith('foo', { forceRefresh: false });
221
+ });
222
+
223
+ test('GET /registry forwards refresh true to npm lookup', async () => {
224
+ const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
225
+ createRegistryApp(getNpmInfo);
226
+
227
+ await request(app).get('/api/config/plugins/registry?specs=foo&refresh=true').expect(200);
228
+
229
+ expect(getNpmInfo).toHaveBeenCalledWith('foo', { forceRefresh: true });
230
+ });
231
+
232
+ test('GET /registry rejects more than 100 unique specs', async () => {
233
+ const specs = Array.from({ length: 101 }, (_, index) => `pkg-${index}`).join(',');
234
+
235
+ const response = await request(app).get(`/api/config/plugins/registry?specs=${specs}`).expect(400);
236
+
237
+ expect(response.body).toEqual({ error: 'too many specs' });
238
+ });
239
+
240
+ test('GET /registry reports bare npm name with null current version', async () => {
241
+ createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
242
+
243
+ const response = await request(app).get('/api/config/plugins/registry?specs=foo').expect(200);
244
+
245
+ expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: 'foo', name: 'foo', currentVersion: null, hasUpdate: false });
246
+ });
247
+
248
+ test('GET /registry accepts non-exact npm range without missing-version noise', async () => {
249
+ createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
250
+
251
+ const response = await request(app).get('/api/config/plugins/registry?specs=foo@%5E1.0').expect(200);
252
+
253
+ expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: 'foo@^1.0', name: 'foo', currentVersion: '^1.0', hasUpdate: false });
254
+ });
255
+
256
+ test('GET /registry supports scoped npm package specs', async () => {
257
+ const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
258
+ createRegistryApp(getNpmInfo);
259
+
260
+ const response = await request(app).get('/api/config/plugins/registry?specs=%40scope%2Ffoo%401.0.0').expect(200);
261
+
262
+ expect(getNpmInfo).toHaveBeenCalledWith('@scope/foo', { forceRefresh: false });
263
+ expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: '@scope/foo@1.0.0', name: '@scope/foo' });
264
+ });
265
+
266
+ test('POST /entry creates entry and requires reload', async () => {
267
+ const response = await createEntry('a');
268
+
269
+ expect(response.body).toMatchObject({ success: true, requiresReload: true, reloadDelayMs: 25 });
270
+ expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry creation');
271
+ });
272
+
273
+ test('GET after POST returns created entry', async () => {
274
+ await createEntry('a');
275
+
276
+ const response = await request(app).get('/api/config/plugins').expect(200);
277
+
278
+ expect(response.body.entries).toEqual([expect.objectContaining({ spec: 'a', scope: 'user' })]);
279
+ });
280
+
281
+ test('POST duplicate entry returns 409', async () => {
282
+ await createEntry('a');
283
+
284
+ const response = await request(app)
285
+ .post('/api/config/plugins/entry')
286
+ .send({ spec: 'a', scope: 'user' })
287
+ .expect(409);
288
+
289
+ expect(response.body.error).toContain('already exists');
290
+ });
291
+
292
+ test('PATCH /entry/:id updates entry in same array index', async () => {
293
+ await createEntry('a');
294
+ const before = await request(app).get('/api/config/plugins').expect(200);
295
+ const id = before.body.entries[0].id;
296
+
297
+ const response = await request(app)
298
+ .patch(`/api/config/plugins/entry/${encodeURIComponent(id)}`)
299
+ .send({ spec: 'b' })
300
+ .expect(200);
301
+
302
+ expect(response.body.success).toBe(true);
303
+ const after = await request(app).get('/api/config/plugins').expect(200);
304
+ expect(after.body.entries[0]).toEqual(expect.objectContaining({ spec: 'b', scope: 'user' }));
305
+ expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry update');
306
+ });
307
+
308
+ test('DELETE /entry/:id removes entry and prunes plugin key', async () => {
309
+ await createEntry('a');
310
+ const listed = await request(app).get('/api/config/plugins').expect(200);
311
+ const id = listed.body.entries[0].id;
312
+
313
+ await request(app).delete(`/api/config/plugins/entry/${encodeURIComponent(id)}`).expect(200);
314
+
315
+ const after = await request(app).get('/api/config/plugins').expect(200);
316
+ expect(after.body.entries).toEqual([]);
317
+ expect(readJson(userConfigPath).plugin).toBeUndefined();
318
+ expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry deletion');
319
+ });
320
+
321
+ test('POST /file writes plugin dir file', async () => {
322
+ const response = await createFile('test.js', '//x');
323
+
324
+ expect(response.body).toMatchObject({ success: true, requiresReload: true });
325
+ expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//x');
326
+ expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file creation');
327
+ });
328
+
329
+ test('POST duplicate file returns 409', async () => {
330
+ await createFile('test.js', '//x');
331
+
332
+ const response = await request(app)
333
+ .post('/api/config/plugins/file')
334
+ .send({ fileName: 'test.js', content: '//again', scope: 'user' })
335
+ .expect(409);
336
+
337
+ expect(response.body.error).toContain('already exists');
338
+ });
339
+
340
+ test('PUT /file/:id updates file content', async () => {
341
+ await createFile('test.js', '//x');
342
+ const listed = await request(app).get('/api/config/plugins').expect(200);
343
+ const id = listed.body.files[0].id;
344
+
345
+ await request(app)
346
+ .put(`/api/config/plugins/file/${encodeURIComponent(id)}`)
347
+ .send({ content: '//y' })
348
+ .expect(200);
349
+
350
+ expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//y');
351
+ expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file update');
352
+ });
353
+
354
+ test('DELETE /file/:id unlinks file', async () => {
355
+ await createFile('test.js', '//x');
356
+ const listed = await request(app).get('/api/config/plugins').expect(200);
357
+ const id = listed.body.files[0].id;
358
+
359
+ await request(app).delete(`/api/config/plugins/file/${encodeURIComponent(id)}`).expect(200);
360
+
361
+ expect(fs.existsSync(path.join(rootDir, 'plugins', 'test.js'))).toBe(false);
362
+ expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file deletion');
363
+ });
364
+
365
+ test('PATCH unknown entry id returns 404', async () => {
366
+ const id = plugins.encodePluginId('config', 'user:missing');
367
+
368
+ const response = await request(app)
369
+ .patch(`/api/config/plugins/entry/${encodeURIComponent(id)}`)
370
+ .send({ spec: 'b' })
371
+ .expect(404);
372
+
373
+ expect(response.body.error).toContain('not found');
374
+ });
375
+
376
+ test('POST invalid fileName returns 400', async () => {
377
+ const response = await request(app)
378
+ .post('/api/config/plugins/file')
379
+ .send({ fileName: '../escape.js', content: '//x', scope: 'user' })
380
+ .expect(400);
381
+
382
+ expect(response.body.error).toContain('Plugin file name');
383
+ });
384
+ });
@@ -0,0 +1,107 @@
1
+ import path from 'path';
2
+
3
+ /**
4
+ * @typedef {Object} ParsedNpmSpec
5
+ * @property {string} name
6
+ * @property {string|null} version
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} MalformedSpec
11
+ * @property {true} malformed
12
+ * @property {string} raw
13
+ */
14
+
15
+ /**
16
+ * @typedef {Object} ParsedPathSpec
17
+ * @property {string} absolutePath
18
+ */
19
+
20
+ /**
21
+ * Parse an npm package spec string into name + version.
22
+ * Handles scoped packages (`@scope/name[@version]`) and unscoped (`name[@version]`).
23
+ * Non-string inputs are coerced via `String()` and returned as malformed.
24
+ *
25
+ * @param {unknown} spec
26
+ * @returns {ParsedNpmSpec | MalformedSpec}
27
+ */
28
+ export function parseNpmSpec(spec) {
29
+ if (typeof spec !== 'string') {
30
+ return { malformed: true, raw: String(spec) };
31
+ }
32
+
33
+ if (spec.startsWith('@')) {
34
+ // scoped: '@scope/name' or '@scope/name@version'
35
+ const slashIdx = spec.indexOf('/');
36
+ if (slashIdx < 2) return { malformed: true, raw: spec }; // '@' or '@/foo'
37
+ const afterSlash = spec.slice(slashIdx + 1);
38
+ if (afterSlash === '') return { malformed: true, raw: spec }; // '@scope/'
39
+ const atIdx = afterSlash.indexOf('@');
40
+ if (atIdx === -1) return { name: spec, version: null };
41
+ const namePart = spec.slice(0, slashIdx + 1 + atIdx); // '@scope/name'
42
+ const versionPart = afterSlash.slice(atIdx + 1);
43
+ if (versionPart === '') return { malformed: true, raw: spec }; // '@scope/foo@'
44
+ return { name: namePart, version: versionPart };
45
+ }
46
+
47
+ // unscoped
48
+ if (spec === '') return { malformed: true, raw: spec };
49
+ const atIdx = spec.indexOf('@');
50
+ if (atIdx === -1) return { name: spec, version: null };
51
+ if (atIdx === 0) return { malformed: true, raw: spec }; // bare '@'
52
+ const namePart = spec.slice(0, atIdx);
53
+ const versionPart = spec.slice(atIdx + 1);
54
+ if (versionPart === '') return { malformed: true, raw: spec }; // 'foo@'
55
+ return { name: namePart, version: versionPart };
56
+ }
57
+
58
+ /**
59
+ * Check whether a version string is an exact semver (no range operators).
60
+ * Accepts optional pre-release (`-label`) or build metadata (`+label`) suffixes.
61
+ *
62
+ * @param {string} version
63
+ * @returns {boolean}
64
+ */
65
+ export function isExactSemver(version) {
66
+ return /^\d+\.\d+\.\d+([-+][\w.-]+)?$/.test(version);
67
+ }
68
+
69
+ /**
70
+ * Check whether a plugin spec is path-like instead of an npm package spec.
71
+ * Includes Windows absolute paths so local paths are never queried against npm.
72
+ *
73
+ * @param {string} spec
74
+ * @returns {boolean}
75
+ */
76
+ export function isPathSpec(spec) {
77
+ return spec.startsWith('/')
78
+ || spec.startsWith('./')
79
+ || spec.startsWith('../')
80
+ || spec.startsWith('~')
81
+ || path.win32.isAbsolute(spec);
82
+ }
83
+
84
+ /**
85
+ * Resolve a path-style plugin spec to an absolute path.
86
+ * Supports `~` (home), `./`, `../` (relative to cwd), and absolute paths.
87
+ * Pure — no filesystem access; uses only `path.resolve`.
88
+ *
89
+ * @param {string} spec
90
+ * @param {{ homedir: string, cwd: string }} options
91
+ * @returns {ParsedPathSpec}
92
+ */
93
+ export function parsePathSpec(spec, { homedir, cwd }) {
94
+ if (spec === '~') {
95
+ return { absolutePath: path.resolve(homedir) };
96
+ }
97
+ if (spec.startsWith('~/')) {
98
+ return { absolutePath: path.resolve(homedir, spec.slice(2)) };
99
+ }
100
+ if (spec.startsWith('./') || spec.startsWith('../')) {
101
+ return { absolutePath: path.resolve(cwd, spec) };
102
+ }
103
+ if (path.win32.isAbsolute(spec)) {
104
+ return { absolutePath: spec };
105
+ }
106
+ return { absolutePath: path.resolve(spec) };
107
+ }
@@ -0,0 +1,154 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import * as spec from './plugin-spec.js';
3
+
4
+ describe('parseNpmSpec', () => {
5
+ test('unscoped: no version', () => {
6
+ expect(spec.parseNpmSpec('foo')).toEqual({ name: 'foo', version: null });
7
+ });
8
+
9
+ test('unscoped: exact version', () => {
10
+ expect(spec.parseNpmSpec('foo@1.2.3')).toEqual({ name: 'foo', version: '1.2.3' });
11
+ });
12
+
13
+ test('unscoped: range version', () => {
14
+ expect(spec.parseNpmSpec('foo@^1.2.0')).toEqual({ name: 'foo', version: '^1.2.0' });
15
+ });
16
+
17
+ test('unscoped: dist-tag', () => {
18
+ expect(spec.parseNpmSpec('foo@latest')).toEqual({ name: 'foo', version: 'latest' });
19
+ });
20
+
21
+ test('scoped: no version', () => {
22
+ expect(spec.parseNpmSpec('@scope/foo')).toEqual({ name: '@scope/foo', version: null });
23
+ });
24
+
25
+ test('scoped: exact version', () => {
26
+ expect(spec.parseNpmSpec('@scope/foo@1.2.3')).toEqual({ name: '@scope/foo', version: '1.2.3' });
27
+ });
28
+
29
+ test('scoped: dist-tag', () => {
30
+ expect(spec.parseNpmSpec('@scope/foo@beta')).toEqual({ name: '@scope/foo', version: 'beta' });
31
+ });
32
+
33
+ test('malformed: empty string', () => {
34
+ expect(spec.parseNpmSpec('')).toEqual({ malformed: true, raw: '' });
35
+ });
36
+
37
+ test('malformed: bare @', () => {
38
+ expect(spec.parseNpmSpec('@')).toEqual({ malformed: true, raw: '@' });
39
+ });
40
+
41
+ test('malformed: @@', () => {
42
+ expect(spec.parseNpmSpec('@@')).toEqual({ malformed: true, raw: '@@' });
43
+ });
44
+
45
+ test('malformed: empty version after @', () => {
46
+ expect(spec.parseNpmSpec('foo@')).toEqual({ malformed: true, raw: 'foo@' });
47
+ });
48
+
49
+ test('malformed: scoped empty name after slash', () => {
50
+ expect(spec.parseNpmSpec('@scope/')).toEqual({ malformed: true, raw: '@scope/' });
51
+ });
52
+
53
+ test('malformed: scoped empty version', () => {
54
+ expect(spec.parseNpmSpec('@scope/foo@')).toEqual({ malformed: true, raw: '@scope/foo@' });
55
+ });
56
+
57
+ test('malformed: null input', () => {
58
+ expect(spec.parseNpmSpec(null)).toEqual({ malformed: true, raw: 'null' });
59
+ });
60
+
61
+ test('malformed: undefined input', () => {
62
+ expect(spec.parseNpmSpec(undefined)).toEqual({ malformed: true, raw: 'undefined' });
63
+ });
64
+
65
+ test('malformed: number input', () => {
66
+ expect(spec.parseNpmSpec(42)).toEqual({ malformed: true, raw: '42' });
67
+ });
68
+
69
+ test('malformed: array input', () => {
70
+ expect(spec.parseNpmSpec(['foo'])).toEqual({ malformed: true, raw: 'foo' });
71
+ });
72
+
73
+ test('malformed: object input', () => {
74
+ expect(spec.parseNpmSpec({})).toEqual({ malformed: true, raw: '[object Object]' });
75
+ });
76
+ });
77
+
78
+ describe('isExactSemver', () => {
79
+ test('plain semver', () => {
80
+ expect(spec.isExactSemver('1.2.3')).toBe(true);
81
+ });
82
+
83
+ test('semver with pre-release', () => {
84
+ expect(spec.isExactSemver('1.2.3-beta.1')).toBe(true);
85
+ });
86
+
87
+ test('semver with build metadata', () => {
88
+ expect(spec.isExactSemver('1.2.3+build.5')).toBe(true);
89
+ });
90
+
91
+ test('range: caret', () => {
92
+ expect(spec.isExactSemver('^1.2.0')).toBe(false);
93
+ });
94
+
95
+ test('dist-tag', () => {
96
+ expect(spec.isExactSemver('latest')).toBe(false);
97
+ });
98
+
99
+ test('empty string', () => {
100
+ expect(spec.isExactSemver('')).toBe(false);
101
+ });
102
+
103
+ test('partial: major.minor only', () => {
104
+ expect(spec.isExactSemver('1.2')).toBe(false);
105
+ });
106
+
107
+ test('partial: major only', () => {
108
+ expect(spec.isExactSemver('1')).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe('parsePathSpec', () => {
113
+ test('identifies Windows absolute paths as path specs', () => {
114
+ expect(spec.isPathSpec('C:\\Users\\me\\plugin.js')).toBe(true);
115
+ expect(spec.isPathSpec('\\\\server\\share\\plugin.js')).toBe(true);
116
+ expect(spec.isPathSpec('@scope/plugin')).toBe(false);
117
+ });
118
+
119
+ test('tilde home shorthand with subpath', () => {
120
+ expect(spec.parsePathSpec('~/x.js', { homedir: '/home/u', cwd: '/p' })).toEqual({
121
+ absolutePath: '/home/u/x.js',
122
+ });
123
+ });
124
+
125
+ test('bare tilde = homedir', () => {
126
+ expect(spec.parsePathSpec('~', { homedir: '/home/u', cwd: '/p' })).toEqual({
127
+ absolutePath: '/home/u',
128
+ });
129
+ });
130
+
131
+ test('relative ./', () => {
132
+ expect(spec.parsePathSpec('./x.js', { homedir: '/home/u', cwd: '/p' })).toEqual({
133
+ absolutePath: '/p/x.js',
134
+ });
135
+ });
136
+
137
+ test('relative ../', () => {
138
+ expect(spec.parsePathSpec('../x.js', { homedir: '/home/u', cwd: '/p/a' })).toEqual({
139
+ absolutePath: '/p/x.js',
140
+ });
141
+ });
142
+
143
+ test('absolute path passthrough', () => {
144
+ expect(spec.parsePathSpec('/abs/x.js', { homedir: '/home/u', cwd: '/p' })).toEqual({
145
+ absolutePath: '/abs/x.js',
146
+ });
147
+ });
148
+
149
+ test('Windows absolute path passthrough', () => {
150
+ expect(spec.parsePathSpec('C:\\Users\\me\\plugin.js', { homedir: '/home/u', cwd: '/p' })).toEqual({
151
+ absolutePath: 'C:\\Users\\me\\plugin.js',
152
+ });
153
+ });
154
+ });