* feat(settings): add opencode plugins page Manage opencode `plugin` array entries (npm, scoped npm, versioned, local paths) and auto-loaded plugin files in `~/.config/opencode/plugins/` and `<project>/.opencode/plugins/`. Mirrors MCP CRUD pattern. - Server: `plugins.js` data layer + `plugin-routes.js` REST routes - UI: PluginsSidebar / PluginsPage / AddPluginDialog - Store: usePluginsStore (cache TTL, in-flight dedup, narrow selectors) - i18n: 41 keys across 7 locales Whitelist /api/config/plugins in JSON body-parser so POST/PATCH bodies parse; opencode plugin specs runtime-resolve OPENCODE_CONFIG dir so parallel test files do not cross-pollute module-frozen consts. * feat(settings/plugins): hook npm registry for update + invalid-version detection Plugins page now consults registry.npmjs.org with a 1h server cache. Sidebar rows show an update badge with the latest version, group headers show how many updates are available, the kebab adds an "Update to latest" action that reuses the existing PATCH+restart flow, and the editor surfaces a banner for update-available / missing-version / missing-package / malformed / missing-path / unreadable-path / offline-registry states. A refresh button in the sidebar header forces a cache bypass. - Server: `npm-registry.js` (cache + in-flight dedup + 5s timeout, 404 cached, network failures NOT cached) + `plugin-spec.js` (parser + exact semver detection) + `GET /api/config/plugins/registry?specs=...&refresh=` - Routes accept up to 100 specs/request, dedup by npm package name before fetching, classify each result by kind, never propagate network failure as 500. - Client: `registryInfo` slice + `loadRegistryInfo` (fire-and-forget after loadPlugins, refreshes on mutations) + `updateToLatest(id)`. - UI: `RegistryBadge` per-row + `RegistryBanner` per-entry editor, both use theme tokens (text-only color, no new bg/border tokens) and the shared Icon sprite. Per-spec subscriptions only. - i18n: 24 new keys (incl. split singular/plural for "N update(s) available" because the runtime does not parse ICU plural format). * fix(settings/plugins): keep registry badge visible for long specs Sidebar entry row used `inline-flex` with `truncate` only on the spec text. With long npm specs the badge could be pushed past the row edge and clipped by the parent overflow. Switch to `flex` with spec `flex-1 min-w-0 truncate` and add `shrink-0` to the badge wrapper so the update indicator stays anchored to the right of the row. * fix(settings/plugins): use code-box icon to distinguish from MCP Plugins nav entry used 'plug' which is visually too close to MCP's 'plug-2' icon. Swap to 'code-box' for clearer differentiation in the Settings nav list. * Update packages/ui/src/components/sections/plugins/PluginsPage.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com> * Update packages/ui/src/stores/usePluginsStore.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com> * fix(settings/plugins): validate registry directory + surface save errors - registry endpoint: return 400 on invalid directory query (was silently falling back to homedir, breaking relative path specs) - save failure toast: prefer result.message over generic 'Reload failed' * fix(settings/plugins): address review follow-ups --------- Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
385 lines
16 KiB
JavaScript
385 lines
16 KiB
JavaScript
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
import express from 'express';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import request from 'supertest';
|
|
|
|
import { registerPluginRoutes } from './plugin-routes.js';
|
|
|
|
let projectDir;
|
|
let userConfigPath;
|
|
let rootDir;
|
|
let plugins;
|
|
let refreshOpenCodeAfterConfigChange;
|
|
let app;
|
|
let cleanupPaths;
|
|
|
|
const testUnlessRoot = typeof process.getuid === 'function' && process.getuid() === 0 ? test.skip : test;
|
|
|
|
function readJson(filePath) {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
function createApp(overrides = {}) {
|
|
const testApp = express();
|
|
testApp.use(express.json());
|
|
registerPluginRoutes(testApp, {
|
|
resolveOptionalProjectDirectory: async () => ({ directory: projectDir, error: null }),
|
|
refreshOpenCodeAfterConfigChange,
|
|
clientReloadDelayMs: 25,
|
|
listPluginEntries: plugins.listPluginEntries,
|
|
getPluginEntry: plugins.getPluginEntry,
|
|
createPluginEntry: plugins.createPluginEntry,
|
|
updatePluginEntry: plugins.updatePluginEntry,
|
|
deletePluginEntry: plugins.deletePluginEntry,
|
|
listPluginDirFiles: plugins.listPluginDirFiles,
|
|
readPluginDirFile: plugins.readPluginDirFile,
|
|
writePluginDirFile: plugins.writePluginDirFile,
|
|
deletePluginDirFile: plugins.deletePluginDirFile,
|
|
encodePluginId: plugins.encodePluginId,
|
|
decodePluginId: plugins.decodePluginId,
|
|
...overrides,
|
|
});
|
|
return testApp;
|
|
}
|
|
|
|
function createRegistryApp(getNpmInfo) {
|
|
app = createApp({ getNpmInfo });
|
|
return app;
|
|
}
|
|
|
|
async function createEntry(spec = 'a') {
|
|
return request(app)
|
|
.post('/api/config/plugins/entry')
|
|
.send({ spec, scope: 'user' })
|
|
.expect(200);
|
|
}
|
|
|
|
async function createFile(fileName = 'test.js', content = '//x') {
|
|
return request(app)
|
|
.post('/api/config/plugins/file')
|
|
.send({ fileName, content, scope: 'user' })
|
|
.expect(200);
|
|
}
|
|
|
|
describe('opencode plugin routes', () => {
|
|
beforeAll(async () => {
|
|
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-plugin-routes-'));
|
|
userConfigPath = path.join(rootDir, 'user-opencode.json');
|
|
process.env.OPENCODE_CONFIG = userConfigPath;
|
|
plugins = await import('./plugins.js');
|
|
});
|
|
|
|
beforeEach(() => {
|
|
projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'));
|
|
fs.rmSync(userConfigPath, { force: true });
|
|
fs.rmSync(path.join(rootDir, 'plugins'), { recursive: true, force: true });
|
|
refreshOpenCodeAfterConfigChange = mock(async () => undefined);
|
|
cleanupPaths = [];
|
|
app = createApp();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const target of cleanupPaths) {
|
|
try {
|
|
fs.chmodSync(target, 0o600);
|
|
} catch {
|
|
}
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
delete process.env.OPENCODE_CONFIG;
|
|
});
|
|
|
|
test('GET /api/config/plugins empty returns entries and files arrays', async () => {
|
|
const response = await request(app).get('/api/config/plugins').expect(200);
|
|
|
|
expect(response.body).toEqual({ entries: [], files: [] });
|
|
});
|
|
|
|
test('GET /registry with empty specs returns empty results', async () => {
|
|
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
|
|
createRegistryApp(getNpmInfo);
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=').expect(200);
|
|
|
|
expect(response.body).toEqual({ results: [] });
|
|
expect(getNpmInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('GET /registry reports update for exact npm version behind latest', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({
|
|
kind: 'npm-ok',
|
|
spec: 'foo@1.0.0',
|
|
name: 'foo',
|
|
currentVersion: '1.0.0',
|
|
latestVersion: '2.0.0',
|
|
hasUpdate: true,
|
|
});
|
|
});
|
|
|
|
test('GET /registry reports no update when exact npm version matches latest', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', hasUpdate: false, latestVersion: '1.0.0', currentVersion: '1.0.0' });
|
|
});
|
|
|
|
test('GET /registry reports missing exact npm version', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=foo@99.99.99').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-missing-version', name: 'foo', currentVersion: '99.99.99', latestVersion: '2.0.0' });
|
|
});
|
|
|
|
test('GET /registry reports missing npm package', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: false, status: 404, error: 'Package not found' })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=nonexistent@1.0.0').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-missing-package', spec: 'nonexistent@1.0.0', name: 'nonexistent', error: 'Package not found' });
|
|
});
|
|
|
|
test('GET /registry reports malformed npm spec', async () => {
|
|
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
|
|
createRegistryApp(getNpmInfo);
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=%40%40malformed').expect(200);
|
|
|
|
expect(response.body.results[0]).toEqual({ kind: 'npm-malformed', spec: '@@malformed', error: 'Spec syntax is malformed' });
|
|
expect(getNpmInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('GET /registry reports existing path plugin ok', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
|
|
const tmpFile = path.join(fs.mkdtempSync(path.join(rootDir, 'plugin-path-')), 'plugin.js');
|
|
fs.writeFileSync(tmpFile, '// plugin', 'utf8');
|
|
|
|
const response = await request(app).get(`/api/config/plugins/registry?specs=${encodeURIComponent(tmpFile)}`).expect(200);
|
|
|
|
expect(response.body.results[0]).toEqual({ kind: 'path-ok', spec: tmpFile, absolutePath: tmpFile });
|
|
});
|
|
|
|
test('GET /registry reports missing path plugin', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=%2Fnonexistent%2F__path%2Fxyz.js').expect(200);
|
|
|
|
expect(response.body.results[0]).toEqual({ kind: 'path-missing', spec: '/nonexistent/__path/xyz.js', absolutePath: '/nonexistent/__path/xyz.js' });
|
|
});
|
|
|
|
test('GET /registry treats Windows absolute paths as local paths', async () => {
|
|
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
|
|
createRegistryApp(getNpmInfo);
|
|
|
|
const windowsPath = 'C:\\Users\\me\\plugin.js';
|
|
const response = await request(app)
|
|
.get(`/api/config/plugins/registry?specs=${encodeURIComponent(windowsPath)}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.results[0]).toEqual({ kind: 'path-missing', spec: windowsPath, absolutePath: windowsPath });
|
|
expect(getNpmInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
testUnlessRoot('GET /registry reports unreadable path plugin', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
|
|
const tmpFile = path.join(fs.mkdtempSync(path.join(rootDir, 'plugin-unreadable-')), 'plugin.js');
|
|
fs.writeFileSync(tmpFile, '// plugin', 'utf8');
|
|
cleanupPaths.push(tmpFile);
|
|
fs.chmodSync(tmpFile, 0);
|
|
|
|
const response = await request(app).get(`/api/config/plugins/registry?specs=${encodeURIComponent(tmpFile)}`).expect(200);
|
|
|
|
expect(response.body.results[0]).toEqual({ kind: 'path-unreadable', spec: tmpFile, absolutePath: tmpFile });
|
|
});
|
|
|
|
test('GET /registry reports npm network failure without failing route', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: false, status: 'network', error: 'socket closed' })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-network', spec: 'foo@1.0.0', error: 'socket closed' });
|
|
});
|
|
|
|
test('GET /registry deduplicates npm package lookups by name', async () => {
|
|
const getNpmInfo = mock(async () => ({ ok: true, latest: '3.0.0', versions: ['1', '2', '3'], distTags: { latest: '3.0.0' } }));
|
|
createRegistryApp(getNpmInfo);
|
|
|
|
await request(app).get('/api/config/plugins/registry?specs=foo@1,foo@2,foo@3').expect(200);
|
|
|
|
expect(getNpmInfo).toHaveBeenCalledTimes(1);
|
|
expect(getNpmInfo).toHaveBeenCalledWith('foo', { forceRefresh: false });
|
|
});
|
|
|
|
test('GET /registry forwards refresh true to npm lookup', async () => {
|
|
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
|
|
createRegistryApp(getNpmInfo);
|
|
|
|
await request(app).get('/api/config/plugins/registry?specs=foo&refresh=true').expect(200);
|
|
|
|
expect(getNpmInfo).toHaveBeenCalledWith('foo', { forceRefresh: true });
|
|
});
|
|
|
|
test('GET /registry rejects more than 100 unique specs', async () => {
|
|
const specs = Array.from({ length: 101 }, (_, index) => `pkg-${index}`).join(',');
|
|
|
|
const response = await request(app).get(`/api/config/plugins/registry?specs=${specs}`).expect(400);
|
|
|
|
expect(response.body).toEqual({ error: 'too many specs' });
|
|
});
|
|
|
|
test('GET /registry reports bare npm name with null current version', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=foo').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: 'foo', name: 'foo', currentVersion: null, hasUpdate: false });
|
|
});
|
|
|
|
test('GET /registry accepts non-exact npm range without missing-version noise', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=foo@%5E1.0').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: 'foo@^1.0', name: 'foo', currentVersion: '^1.0', hasUpdate: false });
|
|
});
|
|
|
|
test('GET /registry supports scoped npm package specs', async () => {
|
|
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
|
|
createRegistryApp(getNpmInfo);
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=%40scope%2Ffoo%401.0.0').expect(200);
|
|
|
|
expect(getNpmInfo).toHaveBeenCalledWith('@scope/foo', { forceRefresh: false });
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: '@scope/foo@1.0.0', name: '@scope/foo' });
|
|
});
|
|
|
|
test('POST /entry creates entry and requires reload', async () => {
|
|
const response = await createEntry('a');
|
|
|
|
expect(response.body).toMatchObject({ success: true, requiresReload: true, reloadDelayMs: 25 });
|
|
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry creation');
|
|
});
|
|
|
|
test('GET after POST returns created entry', async () => {
|
|
await createEntry('a');
|
|
|
|
const response = await request(app).get('/api/config/plugins').expect(200);
|
|
|
|
expect(response.body.entries).toEqual([expect.objectContaining({ spec: 'a', scope: 'user' })]);
|
|
});
|
|
|
|
test('POST duplicate entry returns 409', async () => {
|
|
await createEntry('a');
|
|
|
|
const response = await request(app)
|
|
.post('/api/config/plugins/entry')
|
|
.send({ spec: 'a', scope: 'user' })
|
|
.expect(409);
|
|
|
|
expect(response.body.error).toContain('already exists');
|
|
});
|
|
|
|
test('PATCH /entry/:id updates entry in same array index', async () => {
|
|
await createEntry('a');
|
|
const before = await request(app).get('/api/config/plugins').expect(200);
|
|
const id = before.body.entries[0].id;
|
|
|
|
const response = await request(app)
|
|
.patch(`/api/config/plugins/entry/${encodeURIComponent(id)}`)
|
|
.send({ spec: 'b' })
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
const after = await request(app).get('/api/config/plugins').expect(200);
|
|
expect(after.body.entries[0]).toEqual(expect.objectContaining({ spec: 'b', scope: 'user' }));
|
|
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry update');
|
|
});
|
|
|
|
test('DELETE /entry/:id removes entry and prunes plugin key', async () => {
|
|
await createEntry('a');
|
|
const listed = await request(app).get('/api/config/plugins').expect(200);
|
|
const id = listed.body.entries[0].id;
|
|
|
|
await request(app).delete(`/api/config/plugins/entry/${encodeURIComponent(id)}`).expect(200);
|
|
|
|
const after = await request(app).get('/api/config/plugins').expect(200);
|
|
expect(after.body.entries).toEqual([]);
|
|
expect(readJson(userConfigPath).plugin).toBeUndefined();
|
|
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry deletion');
|
|
});
|
|
|
|
test('POST /file writes plugin dir file', async () => {
|
|
const response = await createFile('test.js', '//x');
|
|
|
|
expect(response.body).toMatchObject({ success: true, requiresReload: true });
|
|
expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//x');
|
|
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file creation');
|
|
});
|
|
|
|
test('POST duplicate file returns 409', async () => {
|
|
await createFile('test.js', '//x');
|
|
|
|
const response = await request(app)
|
|
.post('/api/config/plugins/file')
|
|
.send({ fileName: 'test.js', content: '//again', scope: 'user' })
|
|
.expect(409);
|
|
|
|
expect(response.body.error).toContain('already exists');
|
|
});
|
|
|
|
test('PUT /file/:id updates file content', async () => {
|
|
await createFile('test.js', '//x');
|
|
const listed = await request(app).get('/api/config/plugins').expect(200);
|
|
const id = listed.body.files[0].id;
|
|
|
|
await request(app)
|
|
.put(`/api/config/plugins/file/${encodeURIComponent(id)}`)
|
|
.send({ content: '//y' })
|
|
.expect(200);
|
|
|
|
expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//y');
|
|
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file update');
|
|
});
|
|
|
|
test('DELETE /file/:id unlinks file', async () => {
|
|
await createFile('test.js', '//x');
|
|
const listed = await request(app).get('/api/config/plugins').expect(200);
|
|
const id = listed.body.files[0].id;
|
|
|
|
await request(app).delete(`/api/config/plugins/file/${encodeURIComponent(id)}`).expect(200);
|
|
|
|
expect(fs.existsSync(path.join(rootDir, 'plugins', 'test.js'))).toBe(false);
|
|
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file deletion');
|
|
});
|
|
|
|
test('PATCH unknown entry id returns 404', async () => {
|
|
const id = plugins.encodePluginId('config', 'user:missing');
|
|
|
|
const response = await request(app)
|
|
.patch(`/api/config/plugins/entry/${encodeURIComponent(id)}`)
|
|
.send({ spec: 'b' })
|
|
.expect(404);
|
|
|
|
expect(response.body.error).toContain('not found');
|
|
});
|
|
|
|
test('POST invalid fileName returns 400', async () => {
|
|
const response = await request(app)
|
|
.post('/api/config/plugins/file')
|
|
.send({ fileName: '../escape.js', content: '//x', scope: 'user' })
|
|
.expect(400);
|
|
|
|
expect(response.body.error).toContain('Plugin file name');
|
|
});
|
|
});
|