feat: plugin settings (#1375)

* 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>
This commit is contained in:
Quat3rnion
2026-05-25 19:20:04 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent a25e64099c
commit 2b47d899c6
29 changed files with 4667 additions and 0 deletions
@@ -521,6 +521,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
req.path.startsWith('/api/config/snippets') ||
req.path.startsWith('/api/config/settings') ||
req.path.startsWith('/api/config/skills') ||
req.path.startsWith('/api/config/plugins') ||
req.path.startsWith('/api/projects') ||
req.path.startsWith('/api/fs') ||
req.path.startsWith('/api/git') ||
@@ -9,6 +9,9 @@ import { registerSettingsUtilityRoutes } from './core-routes.js';
import { registerProjectIconRoutes } from './project-icon-routes.js';
import { registerScheduledTaskRoutes } from '../scheduled-tasks/routes.js';
import { registerSkillRoutes } from './skill-routes.js';
import { registerPluginRoutes } from './plugin-routes.js';
import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js';
import { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
import { registerOpenCodeRoutes } from './routes.js';
export const createFeatureRoutesRuntime = (dependencies) => {
@@ -129,6 +132,17 @@ export const createFeatureRoutesRuntime = (dependencies) => {
updateSnippet,
deleteSnippet,
expandSnippets,
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
} = await import('./index.js');
registerConfigEntityRoutes(app, {
@@ -158,6 +172,27 @@ export const createFeatureRoutesRuntime = (dependencies) => {
expandSnippets,
});
registerPluginRoutes(app, {
resolveOptionalProjectDirectory,
refreshOpenCodeAfterConfigChange,
clientReloadDelayMs,
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
getNpmInfo,
parseNpmSpec,
parsePathSpec,
isExactSemver,
});
const {
getSkillSources,
discoverSkills,
+19
View File
@@ -66,6 +66,22 @@ export {
deleteMcpConfig,
} from './mcp.js';
export {
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
parsePluginRaw,
serializePluginEntry,
} from './plugins.js';
export {
listSnippets,
getSnippet,
@@ -74,3 +90,6 @@ export {
deleteSnippet,
expandSnippets,
} from './snippets.js';
export { getNpmInfo, lookupNpmPackage, clearCache as clearNpmCache } from './npm-registry.js';
export { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
@@ -0,0 +1,157 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
export const NPM_CACHE_TTL_MS = 3_600_000;
export const NPM_FETCH_TIMEOUT_MS = 5_000;
export const NPM_REGISTRY_BASE = 'https://registry.npmjs.org';
/**
* @typedef {Object} NpmPackagePayload
* @property {true} ok
* @property {string|null} latest
* @property {string[]} versions
* @property {Record<string, string>} distTags
*
* @typedef {Object} NpmLookupError
* @property {false} ok
* @property {number|'network'} status
* @property {string} error
*
* @typedef {NpmPackagePayload | NpmLookupError} NpmLookupResult
* @typedef {{ forceRefresh?: boolean }} NpmInfoOptions
* @typedef {{ fetchedAt: number, payload: NpmLookupResult }} CacheEntry
*/
/** @type {Map<string, CacheEntry>} */
const _cache = new Map();
/** @type {Map<string, Promise<NpmLookupResult>>} */
const _inFlight = new Map();
/** @type {string | null} */
let _userAgent = null;
function _getPackageJsonPath() {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(__dirname, '..', '..', '..', '..', '..', 'package.json');
}
function _getUserAgent() {
if (_userAgent) return _userAgent;
try {
const pkg = JSON.parse(fs.readFileSync(_getPackageJsonPath(), 'utf8'));
_userAgent = `openchamber-server/${typeof pkg.version === 'string' ? pkg.version : '0.0.0'}`;
} catch {
_userAgent = 'openchamber-server/dev';
}
return _userAgent;
}
function encodeName(name) {
return encodeURIComponent(name).replace(/^%40/, '@');
}
function parseDistTags(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value)
.filter((entry) => typeof entry[1] === 'string'),
);
}
function parseVersions(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return [];
}
return Object.keys(value);
}
function cacheResult(name, payload) {
if (payload.ok || payload.status === 404) {
_cache.set(name, { fetchedAt: Date.now(), payload });
}
}
/**
* Fetch package metadata directly from the npm registry.
*
* @param {string} name npm package name
* @returns {Promise<NpmLookupResult>}
*/
export async function lookupNpmPackage(name) {
try {
const response = await fetch(`${NPM_REGISTRY_BASE}/${encodeName(name)}`, {
headers: {
'User-Agent': _getUserAgent(),
Accept: 'application/json',
},
signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS),
});
if (response.ok) {
const data = await response.json();
const distTags = parseDistTags(data?.['dist-tags']);
return {
ok: true,
latest: distTags.latest ?? null,
versions: parseVersions(data?.versions),
distTags,
};
}
if (response.status === 404) {
return { ok: false, status: 404, error: 'Package not found' };
}
return { ok: false, status: response.status, error: `Registry returned ${response.status}` };
} catch (error) {
return { ok: false, status: 'network', error: String(error?.message ?? error) };
}
}
/**
* Fetch package metadata with TTL cache and in-flight request deduplication.
*
* @param {string} name npm package name
* @param {NpmInfoOptions} [options]
* @returns {Promise<NpmLookupResult>}
*/
export async function getNpmInfo(name, options = {}) {
const { forceRefresh = false } = options;
const cached = _cache.get(name);
if (cached && !forceRefresh && Date.now() - cached.fetchedAt < NPM_CACHE_TTL_MS) {
return cached.payload;
}
const existing = _inFlight.get(name);
if (existing && !forceRefresh) {
return existing;
}
const lookup = (async () => {
const result = await lookupNpmPackage(name);
cacheResult(name, result);
return result;
})();
_inFlight.set(name, lookup);
try {
return await lookup;
} finally {
if (_inFlight.get(name) === lookup) {
_inFlight.delete(name);
}
}
}
export function clearCache() {
_cache.clear();
_inFlight.clear();
}
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import * as npm from './npm-registry.js';
const originalFetch = globalThis.fetch;
const originalDateNow = Date.now;
let fetchMock;
function jsonResponse(body, status = 200) {
return Promise.resolve(new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
}));
}
describe('npm registry client', () => {
beforeEach(() => {
npm.clearCache();
Date.now = originalDateNow;
fetchMock = mock(() => jsonResponse({}));
globalThis.fetch = fetchMock;
});
afterEach(() => {
npm.clearCache();
globalThis.fetch = originalFetch;
Date.now = originalDateNow;
});
test('200 success returns latest versions and dist tags', async () => {
fetchMock.mockImplementation(() => jsonResponse({
'dist-tags': { latest: '1.2.0' },
versions: { '1.0.0': {}, '1.2.0': {} },
}));
const result = await npm.lookupNpmPackage('foo');
expect(result).toEqual({
ok: true,
latest: '1.2.0',
versions: ['1.0.0', '1.2.0'],
distTags: { latest: '1.2.0' },
});
});
test('200 success handles missing dist-tags and versions', async () => {
fetchMock.mockImplementation(() => jsonResponse({}));
const result = await npm.lookupNpmPackage('foo');
expect(result).toEqual({ ok: true, latest: null, versions: [], distTags: {} });
});
test('404 returns package not found', async () => {
fetchMock.mockImplementation(() => jsonResponse({}, 404));
const result = await npm.lookupNpmPackage('missing');
expect(result).toEqual({ ok: false, status: 404, error: 'Package not found' });
});
test('500 returns registry error', async () => {
fetchMock.mockImplementation(() => jsonResponse({}, 500));
const result = await npm.lookupNpmPackage('foo');
expect(result).toEqual({ ok: false, status: 500, error: 'Registry returned 500' });
});
test('network error returns network status', async () => {
fetchMock.mockImplementation(() => Promise.reject(new Error('socket closed')));
const result = await npm.lookupNpmPackage('foo');
expect(result.ok).toBe(false);
expect(result.status).toBe('network');
expect(result.error).toBe('socket closed');
});
test('timeout plumbs AbortSignal to fetch', async () => {
fetchMock.mockImplementation((_url, init) => {
expect(init.signal).toBeInstanceOf(AbortSignal);
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
});
const result = await npm.lookupNpmPackage('foo');
expect(result.ok).toBe(false);
expect(result.status).toBe('network');
expect(result.error).toContain('aborted');
});
test('cache hit reuses definitive success', async () => {
fetchMock.mockImplementation(() => jsonResponse({ 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': {} } }));
const first = await npm.getNpmInfo('foo');
const second = await npm.getNpmInfo('foo');
expect(first).toEqual(second);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('cache miss after ttl fetches again', async () => {
let now = 1_000;
Date.now = mock(() => now);
fetchMock.mockImplementation(() => jsonResponse({ versions: {} }));
await npm.getNpmInfo('foo');
now += 3_600_001;
await npm.getNpmInfo('foo');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test('forceRefresh bypasses cache', async () => {
fetchMock.mockImplementation(() => jsonResponse({ versions: {} }));
await npm.getNpmInfo('foo');
await npm.getNpmInfo('foo', { forceRefresh: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test('in-flight requests dedup by package name', async () => {
let release;
const wait = new Promise((resolve) => {
release = resolve;
});
fetchMock.mockImplementation(async () => {
await wait;
return new Response(JSON.stringify({ versions: { '1.0.0': {} } }), { status: 200 });
});
const requests = Promise.all([
npm.getNpmInfo('foo'),
npm.getNpmInfo('foo'),
npm.getNpmInfo('foo'),
]);
release();
const results = await requests;
expect(results.every((result) => result.ok)).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('network failure is not cached', async () => {
fetchMock
.mockImplementationOnce(() => Promise.reject(new Error('down')))
.mockImplementationOnce(() => jsonResponse({ versions: {} }));
const first = await npm.getNpmInfo('foo');
const second = await npm.getNpmInfo('foo');
expect(first.status).toBe('network');
expect(second.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test('404 is cached', async () => {
fetchMock.mockImplementation(() => jsonResponse({}, 404));
await npm.getNpmInfo('missing');
await npm.getNpmInfo('missing');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('scoped names encode slash in registry url', async () => {
await npm.getNpmInfo('@scope/pkg');
expect(fetchMock.mock.calls[0][0]).toBe('https://registry.npmjs.org/@scope%2Fpkg');
});
test('user-agent header is present', async () => {
await npm.getNpmInfo('foo');
expect(fetchMock.mock.calls[0][1].headers['User-Agent']).toMatch(/^openchamber-server\//);
});
});
@@ -0,0 +1,373 @@
import fs from 'fs';
import os from 'os';
import { getNpmInfo as defaultGetNpmInfo } from './npm-registry.js';
import { isExactSemver as defaultIsExactSemver, isPathSpec as defaultIsPathSpec, parseNpmSpec as defaultParseNpmSpec, parsePathSpec as defaultParsePathSpec } from './plugin-spec.js';
const ENTRY_EXISTS_CODES = new Set(['ENTRY_EXISTS', 'EEXIST']);
const FILE_EXISTS_CODES = new Set(['FILE_EXISTS', 'EEXIST']);
const NOT_FOUND_CODES = new Set(['NOT_FOUND', 'ENOENT']);
const BAD_REQUEST_CODES = new Set(['INVALID_FILENAME', 'INVALID_SCOPE', 'INVALID_SPEC', 'EINVAL']);
export const registerPluginRoutes = (app, dependencies) => {
const {
resolveOptionalProjectDirectory,
refreshOpenCodeAfterConfigChange,
clientReloadDelayMs,
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
getNpmInfo = defaultGetNpmInfo,
parseNpmSpec = defaultParseNpmSpec,
parsePathSpec = defaultParsePathSpec,
isExactSemver = defaultIsExactSemver,
isPathSpec = defaultIsPathSpec,
} = dependencies;
const parsedKindForSpec = (spec) => (isPathSpec(spec) ? 'path' : 'npm');
const resolveDirectory = async (req, res) => {
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
res.status(400).json({ error });
return null;
}
return directory || null;
};
const successPayload = (message) => ({
success: true,
requiresReload: true,
message,
reloadDelayMs: clientReloadDelayMs,
reloadFailed: false,
warning: undefined,
});
const completePluginMutation = async (res, operation, _noun, applyChange) => {
applyChange();
const pastTense = operation.replace(/ion$/, 'ed').replace(/update$/, 'updated');
try {
await refreshOpenCodeAfterConfigChange(`plugin ${operation}`);
return res.json(successPayload(`Plugin ${pastTense}. Reloading interface…`));
} catch (error) {
console.error(`[API:plugin ${operation}] Reload failed after config write:`, error);
return res.json({
success: true,
requiresReload: false,
message: `Plugin ${pastTense}, but OpenCode reload failed.`,
reloadDelayMs: clientReloadDelayMs,
reloadFailed: true,
warning: error.message || 'OpenCode reload failed after plugin config changed',
});
}
};
const validateEntryId = (id) => {
const decoded = decodePluginId(id);
if (decoded.prefix !== 'config') {
const error = new Error('Plugin entry not found');
error.code = 'NOT_FOUND';
throw error;
}
};
const validateFileId = (id) => {
const decoded = decodePluginId(id);
if (decoded.prefix !== 'file') {
const error = new Error('Plugin file not found');
error.code = 'NOT_FOUND';
throw error;
}
};
const handlePluginError = (res, error, fallbackMessage, context, existsKind = null) => {
const code = error?.code;
if ((existsKind === 'entry' && ENTRY_EXISTS_CODES.has(code)) || (existsKind === 'file' && FILE_EXISTS_CODES.has(code))) {
return res.status(409).json({ error: error.message });
}
if (NOT_FOUND_CODES.has(code)) {
return res.status(404).json({ error: error.message });
}
if (BAD_REQUEST_CODES.has(code)) {
return res.status(400).json({ error: error.message });
}
console.error(context, error);
return res.status(500).json({ error: fallbackMessage });
};
app.get('/api/config/plugins', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
res.json({
entries: listPluginEntries(directory),
files: listPluginDirFiles(directory),
});
} catch (error) {
console.error('[API:GET /api/config/plugins] Failed:', error);
res.status(500).json({ error: 'Failed to list plugins' });
}
});
app.get('/api/config/plugins/registry', async (req, res) => {
try {
const { directory, error: directoryError } = await resolveOptionalProjectDirectory(req);
if (directoryError) {
return res.status(400).json({ error: directoryError });
}
const rawSpecs = (req.query.specs || '').toString();
const specs = rawSpecs
? rawSpecs.split(',').map((spec) => {
try {
return decodeURIComponent(spec);
} catch {
return spec;
}
}).filter((spec) => spec.length > 0)
: [];
const uniqueSpecs = Array.from(new Set(specs));
if (uniqueSpecs.length > 100) {
return res.status(400).json({ error: 'too many specs' });
}
const refresh = req.query.refresh === 'true';
const npmJobs = new Map();
const malformedSpecs = new Set();
for (const spec of uniqueSpecs) {
if (parsedKindForSpec(spec) !== 'npm') continue;
const parsed = parseNpmSpec(spec);
if (parsed.malformed) {
malformedSpecs.add(spec);
continue;
}
const job = npmJobs.get(parsed.name) || { specs: [], parsedBySpec: new Map() };
job.specs.push(spec);
job.parsedBySpec.set(spec, parsed);
npmJobs.set(parsed.name, job);
}
const npmInfoByName = new Map();
await Promise.all(Array.from(npmJobs.keys()).map(async (name) => {
npmInfoByName.set(name, await getNpmInfo(name, { forceRefresh: refresh }));
}));
const results = [];
for (const spec of uniqueSpecs) {
if (malformedSpecs.has(spec)) {
results.push({ kind: 'npm-malformed', spec, error: 'Spec syntax is malformed' });
continue;
}
if (parsedKindForSpec(spec) === 'path') {
const { absolutePath } = parsePathSpec(spec, { homedir: os.homedir(), cwd: directory || os.homedir() });
try {
fs.statSync(absolutePath);
} catch {
results.push({ kind: 'path-missing', spec, absolutePath });
continue;
}
try {
fs.accessSync(absolutePath, fs.constants.R_OK);
results.push({ kind: 'path-ok', spec, absolutePath });
} catch {
results.push({ kind: 'path-unreadable', spec, absolutePath });
}
continue;
}
const parsed = parseNpmSpec(spec);
const info = npmInfoByName.get(parsed.name);
if (!info.ok) {
if (info.status === 404) {
results.push({ kind: 'npm-missing-package', spec, name: parsed.name, error: info.error });
continue;
}
results.push({ kind: 'npm-network', spec, error: info.status === 'network' ? info.error : `Registry returned ${info.status}` });
continue;
}
const currentVersion = parsed.version;
if (currentVersion !== null && isExactSemver(currentVersion) && !info.versions.includes(currentVersion)) {
results.push({
kind: 'npm-missing-version',
spec,
name: parsed.name,
currentVersion,
latestVersion: info.latest,
versions: info.versions,
});
continue;
}
results.push({
kind: 'npm-ok',
spec,
name: parsed.name,
currentVersion,
latestVersion: info.latest,
versions: info.versions,
hasUpdate: currentVersion !== null && isExactSemver(currentVersion) && currentVersion !== info.latest,
});
}
return res.json({ results });
} catch (error) {
console.error('[API:GET /api/config/plugins/registry]', error);
return res.status(500).json({ error: 'Failed to query npm registry' });
}
});
app.get('/api/config/plugins/entry/:id', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
validateEntryId(req.params.id);
const entry = getPluginEntry(req.params.id, directory);
if (!entry) {
return res.status(404).json({ error: 'Plugin entry not found' });
}
return res.json(entry);
} catch (error) {
return handlePluginError(res, error, 'Failed to get plugin entry', '[API:GET /api/config/plugins/entry/:id] Failed:');
}
});
app.post('/api/config/plugins/entry', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
await completePluginMutation(res, 'entry creation', 'entry', () => {
createPluginEntry({
spec: req.body?.spec,
options: req.body?.options,
scope: req.body?.scope,
}, directory);
});
} catch (error) {
return handlePluginError(res, error, 'Failed to create plugin entry', '[API:POST /api/config/plugins/entry] Failed:', 'entry');
}
});
app.patch('/api/config/plugins/entry/:id', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
validateEntryId(req.params.id);
await completePluginMutation(res, 'entry update', 'entry', () => {
updatePluginEntry(req.params.id, {
spec: req.body?.spec,
options: req.body?.options,
}, directory);
});
} catch (error) {
return handlePluginError(res, error, 'Failed to update plugin entry', '[API:PATCH /api/config/plugins/entry/:id] Failed:', 'entry');
}
});
app.delete('/api/config/plugins/entry/:id', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
validateEntryId(req.params.id);
await completePluginMutation(res, 'entry deletion', 'entry', () => {
deletePluginEntry(req.params.id, directory);
});
} catch (error) {
return handlePluginError(res, error, 'Failed to delete plugin entry', '[API:DELETE /api/config/plugins/entry/:id] Failed:', 'entry');
}
});
app.get('/api/config/plugins/file/:id', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
validateFileId(req.params.id);
const file = readPluginDirFile(req.params.id, directory);
if (!file) {
return res.status(404).json({ error: 'Plugin file not found' });
}
return res.json(file);
} catch (error) {
return handlePluginError(res, error, 'Failed to read plugin file', '[API:GET /api/config/plugins/file/:id] Failed:');
}
});
app.post('/api/config/plugins/file', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
const id = encodePluginId('file', `${req.body?.scope || 'user'}:${req.body?.fileName || ''}`);
await completePluginMutation(res, 'file creation', 'file', () => {
validateFileId(id);
writePluginDirFile({
fileName: req.body?.fileName,
content: req.body?.content,
scope: req.body?.scope,
}, directory);
});
} catch (error) {
return handlePluginError(res, error, 'Failed to create plugin file', '[API:POST /api/config/plugins/file] Failed:', 'file');
}
});
app.put('/api/config/plugins/file/:id', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
validateFileId(req.params.id);
const existing = readPluginDirFile(req.params.id, directory);
if (!existing) {
return res.status(404).json({ error: 'Plugin file not found' });
}
await completePluginMutation(res, 'file update', 'file', () => {
writePluginDirFile({
fileName: existing.fileName,
content: req.body?.content,
scope: existing.scope,
}, directory, { overwrite: true });
});
} catch (error) {
return handlePluginError(res, error, 'Failed to update plugin file', '[API:PUT /api/config/plugins/file/:id] Failed:', 'file');
}
});
app.delete('/api/config/plugins/file/:id', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
validateFileId(req.params.id);
await completePluginMutation(res, 'file deletion', 'file', () => {
deletePluginDirFile(req.params.id, directory);
});
} catch (error) {
return handlePluginError(res, error, 'Failed to delete plugin file', '[API:DELETE /api/config/plugins/file/:id] Failed:', 'file');
}
});
};
@@ -0,0 +1,384 @@
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');
});
});
@@ -0,0 +1,107 @@
import path from 'path';
/**
* @typedef {Object} ParsedNpmSpec
* @property {string} name
* @property {string|null} version
*/
/**
* @typedef {Object} MalformedSpec
* @property {true} malformed
* @property {string} raw
*/
/**
* @typedef {Object} ParsedPathSpec
* @property {string} absolutePath
*/
/**
* Parse an npm package spec string into name + version.
* Handles scoped packages (`@scope/name[@version]`) and unscoped (`name[@version]`).
* Non-string inputs are coerced via `String()` and returned as malformed.
*
* @param {unknown} spec
* @returns {ParsedNpmSpec | MalformedSpec}
*/
export function parseNpmSpec(spec) {
if (typeof spec !== 'string') {
return { malformed: true, raw: String(spec) };
}
if (spec.startsWith('@')) {
// scoped: '@scope/name' or '@scope/name@version'
const slashIdx = spec.indexOf('/');
if (slashIdx < 2) return { malformed: true, raw: spec }; // '@' or '@/foo'
const afterSlash = spec.slice(slashIdx + 1);
if (afterSlash === '') return { malformed: true, raw: spec }; // '@scope/'
const atIdx = afterSlash.indexOf('@');
if (atIdx === -1) return { name: spec, version: null };
const namePart = spec.slice(0, slashIdx + 1 + atIdx); // '@scope/name'
const versionPart = afterSlash.slice(atIdx + 1);
if (versionPart === '') return { malformed: true, raw: spec }; // '@scope/foo@'
return { name: namePart, version: versionPart };
}
// unscoped
if (spec === '') return { malformed: true, raw: spec };
const atIdx = spec.indexOf('@');
if (atIdx === -1) return { name: spec, version: null };
if (atIdx === 0) return { malformed: true, raw: spec }; // bare '@'
const namePart = spec.slice(0, atIdx);
const versionPart = spec.slice(atIdx + 1);
if (versionPart === '') return { malformed: true, raw: spec }; // 'foo@'
return { name: namePart, version: versionPart };
}
/**
* Check whether a version string is an exact semver (no range operators).
* Accepts optional pre-release (`-label`) or build metadata (`+label`) suffixes.
*
* @param {string} version
* @returns {boolean}
*/
export function isExactSemver(version) {
return /^\d+\.\d+\.\d+([-+][\w.-]+)?$/.test(version);
}
/**
* Check whether a plugin spec is path-like instead of an npm package spec.
* Includes Windows absolute paths so local paths are never queried against npm.
*
* @param {string} spec
* @returns {boolean}
*/
export function isPathSpec(spec) {
return spec.startsWith('/')
|| spec.startsWith('./')
|| spec.startsWith('../')
|| spec.startsWith('~')
|| path.win32.isAbsolute(spec);
}
/**
* Resolve a path-style plugin spec to an absolute path.
* Supports `~` (home), `./`, `../` (relative to cwd), and absolute paths.
* Pure — no filesystem access; uses only `path.resolve`.
*
* @param {string} spec
* @param {{ homedir: string, cwd: string }} options
* @returns {ParsedPathSpec}
*/
export function parsePathSpec(spec, { homedir, cwd }) {
if (spec === '~') {
return { absolutePath: path.resolve(homedir) };
}
if (spec.startsWith('~/')) {
return { absolutePath: path.resolve(homedir, spec.slice(2)) };
}
if (spec.startsWith('./') || spec.startsWith('../')) {
return { absolutePath: path.resolve(cwd, spec) };
}
if (path.win32.isAbsolute(spec)) {
return { absolutePath: spec };
}
return { absolutePath: path.resolve(spec) };
}
@@ -0,0 +1,154 @@
import { describe, expect, test } from 'bun:test';
import * as spec from './plugin-spec.js';
describe('parseNpmSpec', () => {
test('unscoped: no version', () => {
expect(spec.parseNpmSpec('foo')).toEqual({ name: 'foo', version: null });
});
test('unscoped: exact version', () => {
expect(spec.parseNpmSpec('foo@1.2.3')).toEqual({ name: 'foo', version: '1.2.3' });
});
test('unscoped: range version', () => {
expect(spec.parseNpmSpec('foo@^1.2.0')).toEqual({ name: 'foo', version: '^1.2.0' });
});
test('unscoped: dist-tag', () => {
expect(spec.parseNpmSpec('foo@latest')).toEqual({ name: 'foo', version: 'latest' });
});
test('scoped: no version', () => {
expect(spec.parseNpmSpec('@scope/foo')).toEqual({ name: '@scope/foo', version: null });
});
test('scoped: exact version', () => {
expect(spec.parseNpmSpec('@scope/foo@1.2.3')).toEqual({ name: '@scope/foo', version: '1.2.3' });
});
test('scoped: dist-tag', () => {
expect(spec.parseNpmSpec('@scope/foo@beta')).toEqual({ name: '@scope/foo', version: 'beta' });
});
test('malformed: empty string', () => {
expect(spec.parseNpmSpec('')).toEqual({ malformed: true, raw: '' });
});
test('malformed: bare @', () => {
expect(spec.parseNpmSpec('@')).toEqual({ malformed: true, raw: '@' });
});
test('malformed: @@', () => {
expect(spec.parseNpmSpec('@@')).toEqual({ malformed: true, raw: '@@' });
});
test('malformed: empty version after @', () => {
expect(spec.parseNpmSpec('foo@')).toEqual({ malformed: true, raw: 'foo@' });
});
test('malformed: scoped empty name after slash', () => {
expect(spec.parseNpmSpec('@scope/')).toEqual({ malformed: true, raw: '@scope/' });
});
test('malformed: scoped empty version', () => {
expect(spec.parseNpmSpec('@scope/foo@')).toEqual({ malformed: true, raw: '@scope/foo@' });
});
test('malformed: null input', () => {
expect(spec.parseNpmSpec(null)).toEqual({ malformed: true, raw: 'null' });
});
test('malformed: undefined input', () => {
expect(spec.parseNpmSpec(undefined)).toEqual({ malformed: true, raw: 'undefined' });
});
test('malformed: number input', () => {
expect(spec.parseNpmSpec(42)).toEqual({ malformed: true, raw: '42' });
});
test('malformed: array input', () => {
expect(spec.parseNpmSpec(['foo'])).toEqual({ malformed: true, raw: 'foo' });
});
test('malformed: object input', () => {
expect(spec.parseNpmSpec({})).toEqual({ malformed: true, raw: '[object Object]' });
});
});
describe('isExactSemver', () => {
test('plain semver', () => {
expect(spec.isExactSemver('1.2.3')).toBe(true);
});
test('semver with pre-release', () => {
expect(spec.isExactSemver('1.2.3-beta.1')).toBe(true);
});
test('semver with build metadata', () => {
expect(spec.isExactSemver('1.2.3+build.5')).toBe(true);
});
test('range: caret', () => {
expect(spec.isExactSemver('^1.2.0')).toBe(false);
});
test('dist-tag', () => {
expect(spec.isExactSemver('latest')).toBe(false);
});
test('empty string', () => {
expect(spec.isExactSemver('')).toBe(false);
});
test('partial: major.minor only', () => {
expect(spec.isExactSemver('1.2')).toBe(false);
});
test('partial: major only', () => {
expect(spec.isExactSemver('1')).toBe(false);
});
});
describe('parsePathSpec', () => {
test('identifies Windows absolute paths as path specs', () => {
expect(spec.isPathSpec('C:\\Users\\me\\plugin.js')).toBe(true);
expect(spec.isPathSpec('\\\\server\\share\\plugin.js')).toBe(true);
expect(spec.isPathSpec('@scope/plugin')).toBe(false);
});
test('tilde home shorthand with subpath', () => {
expect(spec.parsePathSpec('~/x.js', { homedir: '/home/u', cwd: '/p' })).toEqual({
absolutePath: '/home/u/x.js',
});
});
test('bare tilde = homedir', () => {
expect(spec.parsePathSpec('~', { homedir: '/home/u', cwd: '/p' })).toEqual({
absolutePath: '/home/u',
});
});
test('relative ./', () => {
expect(spec.parsePathSpec('./x.js', { homedir: '/home/u', cwd: '/p' })).toEqual({
absolutePath: '/p/x.js',
});
});
test('relative ../', () => {
expect(spec.parsePathSpec('../x.js', { homedir: '/home/u', cwd: '/p/a' })).toEqual({
absolutePath: '/p/x.js',
});
});
test('absolute path passthrough', () => {
expect(spec.parsePathSpec('/abs/x.js', { homedir: '/home/u', cwd: '/p' })).toEqual({
absolutePath: '/abs/x.js',
});
});
test('Windows absolute path passthrough', () => {
expect(spec.parsePathSpec('C:\\Users\\me\\plugin.js', { homedir: '/home/u', cwd: '/p' })).toEqual({
absolutePath: 'C:\\Users\\me\\plugin.js',
});
});
});
+393
View File
@@ -0,0 +1,393 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
AGENT_SCOPE,
readConfigFile,
writeConfig,
} from './shared.js';
import { isPathSpec } from './plugin-spec.js';
const PLUGIN_FILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_.]*\.(js|ts|mjs|cjs)$/;
/**
* @typedef {'user' | 'project'} PluginScope
* @typedef {'npm' | 'path'} PluginParsedKind
* @typedef {Object} PluginEntry
* @property {string} id base64url encoded "config:scope:spec"
* @property {string} spec
* @property {Record<string, unknown>} [options]
* @property {PluginScope} scope
* @property {'config'} kind
* @property {PluginParsedKind} parsedKind
* @property {string} sourcePath absolute path to the config file
* @typedef {Object} PluginFile
* @property {string} id base64url encoded "file:scope:fileName"
* @property {string} fileName
* @property {PluginScope} scope
* @property {'file'} kind
* @property {string} absolutePath
*/
function codedError(message, code) {
const error = new Error(message);
error.code = code;
return error;
}
function validateScope(scope) {
if (scope !== AGENT_SCOPE.USER && scope !== AGENT_SCOPE.PROJECT) {
throw codedError('Plugin scope must be user or project', 'INVALID_SCOPE');
}
}
function validatePluginSpec(spec) {
if (typeof spec !== 'string' || !spec.trim()) {
throw codedError('Plugin spec must be a non-empty string', 'INVALID_SPEC');
}
if (spec.includes('\0')) {
throw codedError('Plugin spec cannot contain null bytes', 'INVALID_SPEC');
}
return spec.trim();
}
function isRecord(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function hasOptions(options) {
return isRecord(options) && Object.keys(options).length > 0;
}
function parsedKindForSpec(spec) {
// Path indicators must include Windows paths; scoped npm packages also contain '/'.
// Do NOT use `includes(path.sep)` — scoped npm packages legitimately contain '/' (e.g. `@gitlab/opencode-gitlab-auth`).
return isPathSpec(spec) ? 'path' : 'npm';
}
function getActiveOpencodeConfigDir() {
const customConfigPath = process.env.OPENCODE_CONFIG;
if (customConfigPath) {
return path.dirname(path.resolve(customConfigPath));
}
return path.join(os.homedir(), '.config', 'opencode');
}
function getActiveUserConfigPaths() {
const configDir = getActiveOpencodeConfigDir();
return [
path.join(configDir, 'config.json'),
path.join(configDir, 'opencode.json'),
path.join(configDir, 'opencode.jsonc'),
];
}
function getActiveCustomConfigPath() {
return process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null;
}
function getPrimaryUserConfigPath() {
const [defaultPath, ...fallbackPaths] = getActiveUserConfigPaths();
for (const userPath of [defaultPath, ...fallbackPaths]) {
if (fs.existsSync(userPath)) {
return userPath;
}
}
return defaultPath;
}
function getProjectConfigPath(workingDirectory) {
if (!workingDirectory) return null;
const candidates = [
path.join(workingDirectory, 'opencode.json'),
path.join(workingDirectory, 'opencode.jsonc'),
path.join(workingDirectory, '.opencode', 'opencode.json'),
path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
];
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
}
function readPluginConfigLayers(workingDirectory) {
const customPath = getActiveCustomConfigPath();
const userPath = getPrimaryUserConfigPath();
const projectPath = getProjectConfigPath(workingDirectory);
return {
userConfig: readConfigFile(userPath),
projectConfig: readConfigFile(projectPath),
customConfig: readConfigFile(customPath),
paths: {
userPath,
projectPath,
customPath,
},
};
}
function validateFileName(fileName) {
if (typeof fileName !== 'string' || !fileName) {
throw codedError('Plugin file name is required', 'INVALID_FILENAME');
}
if (fileName.includes('/') || fileName.includes('\\') || fileName.includes('..') || !PLUGIN_FILE_NAME_PATTERN.test(fileName)) {
throw codedError('Plugin file name must match /^[a-z0-9][a-z0-9-_.]*\\.(js|ts|mjs|cjs)$/ and cannot contain path traversal', 'INVALID_FILENAME');
}
return fileName;
}
function ensureProjectConfigPath(workingDirectory) {
if (!workingDirectory) {
throw codedError('Project scope requires working directory', 'INVALID_SCOPE');
}
const configDir = path.join(workingDirectory, '.opencode');
fs.mkdirSync(configDir, { recursive: true });
return path.join(configDir, 'opencode.json');
}
function configSources(layers) {
const sources = [];
if (layers.paths.customPath) {
sources.push({ config: layers.customConfig, filePath: layers.paths.customPath, scope: AGENT_SCOPE.USER });
} else {
sources.push({ config: layers.userConfig, filePath: layers.paths.userPath, scope: AGENT_SCOPE.USER });
}
if (layers.paths.projectPath) {
sources.push({ config: layers.projectConfig, filePath: layers.paths.projectPath, scope: AGENT_SCOPE.PROJECT });
}
return sources;
}
function splitScopedValue(value) {
const separator = value.indexOf(':');
if (separator === -1) {
throw codedError('Plugin id value must include scope', 'INVALID_SPEC');
}
return {
scope: value.slice(0, separator),
value: value.slice(separator + 1),
};
}
function getPluginTarget(id, workingDirectory) {
const decoded = decodePluginId(id);
if (decoded.prefix !== 'config') {
throw codedError('Plugin entry id must use config prefix', 'INVALID_SPEC');
}
const { scope, value: spec } = splitScopedValue(decoded.value);
validateScope(scope);
const layers = readPluginConfigLayers(workingDirectory);
const source = configSources(layers).find((candidate) => candidate.scope === scope);
const plugin = Array.isArray(source?.config?.plugin) ? source.config.plugin : [];
const index = plugin.findIndex((raw) => parsePluginRaw(raw).spec === spec);
if (!source || index === -1) {
return null;
}
return { source, plugin, index };
}
function pluginDirForScope(scope, workingDirectory) {
validateScope(scope);
if (scope === AGENT_SCOPE.PROJECT) {
if (!workingDirectory) {
throw codedError('Project scope requires working directory', 'INVALID_SCOPE');
}
return path.join(workingDirectory, '.opencode', 'plugins');
}
return path.join(getActiveOpencodeConfigDir(), 'plugins');
}
function fileTargetFromId(id, workingDirectory) {
const decoded = decodePluginId(id);
if (decoded.prefix !== 'file') {
throw codedError('Plugin file id must use file prefix', 'INVALID_FILENAME');
}
const { scope, value: fileName } = splitScopedValue(decoded.value);
validateScope(scope);
validateFileName(fileName);
return {
fileName,
scope,
absolutePath: path.join(pluginDirForScope(scope, workingDirectory), fileName),
};
}
function encodePluginId(prefix, value) {
return Buffer.from(`${prefix}:${value}`).toString('base64url');
}
function decodePluginId(id) {
const decoded = Buffer.from(id, 'base64url').toString('utf8');
const separator = decoded.indexOf(':');
if (separator === -1) {
throw codedError('Invalid plugin id', 'INVALID_SPEC');
}
return { prefix: decoded.slice(0, separator), value: decoded.slice(separator + 1) };
}
function parsePluginRaw(raw) {
if (typeof raw === 'string') {
return { spec: validatePluginSpec(raw) };
}
if (Array.isArray(raw) && raw.length === 2 && isRecord(raw[1])) {
return { spec: validatePluginSpec(raw[0]), options: { ...raw[1] } };
}
throw codedError('Plugin spec must be a string or [string, object]', 'INVALID_SPEC');
}
function serializePluginEntry(entry) {
const spec = validatePluginSpec(entry?.spec);
if (hasOptions(entry?.options)) {
return [spec, { ...entry.options }];
}
return spec;
}
function listPluginEntries(workingDirectory) {
const layers = readPluginConfigLayers(workingDirectory);
return configSources(layers).flatMap((source) => {
if (!Array.isArray(source.config?.plugin)) {
return [];
}
return source.config.plugin.map((raw) => {
const parsed = parsePluginRaw(raw);
return {
id: encodePluginId('config', `${source.scope}:${parsed.spec}`),
spec: parsed.spec,
...(parsed.options !== undefined ? { options: parsed.options } : {}),
scope: source.scope,
kind: 'config',
parsedKind: parsedKindForSpec(parsed.spec),
sourcePath: source.filePath,
};
});
});
}
function getPluginEntry(id, workingDirectory) {
return listPluginEntries(workingDirectory).find((entry) => entry.id === id) || null;
}
function createPluginEntry(entry, workingDirectory) {
const spec = validatePluginSpec(entry?.spec);
const scope = entry?.scope || AGENT_SCOPE.USER;
validateScope(scope);
const layers = readPluginConfigLayers(workingDirectory);
const existing = configSources(layers).find((source) => (
source.scope === scope
&& Array.isArray(source.config?.plugin)
&& source.config.plugin.some((raw) => parsePluginRaw(raw).spec === spec)
));
if (existing) {
throw codedError(`Plugin "${spec}" already exists`, 'ENTRY_EXISTS');
}
let targetPath = getPrimaryUserConfigPath();
let config = {};
if (scope === AGENT_SCOPE.PROJECT) {
targetPath = ensureProjectConfigPath(workingDirectory);
config = fs.existsSync(targetPath) ? readConfigFile(targetPath) : {};
} else {
targetPath = layers.paths.customPath || layers.paths.userPath;
config = layers.paths.customPath ? layers.customConfig : layers.userConfig;
}
if (!Array.isArray(config.plugin)) {
config.plugin = [];
}
config.plugin.push(serializePluginEntry({ spec, options: entry.options }));
writeConfig(config, targetPath);
}
function updatePluginEntry(id, updates, workingDirectory) {
const target = getPluginTarget(id, workingDirectory);
if (!target) {
throw codedError('Plugin entry not found', 'NOT_FOUND');
}
const existing = parsePluginRaw(target.plugin[target.index]);
const nextSpec = updates?.spec === undefined ? existing.spec : validatePluginSpec(updates.spec);
const nextOptions = updates?.options === undefined ? existing.options : updates.options;
target.plugin[target.index] = serializePluginEntry({ spec: nextSpec, options: nextOptions });
writeConfig(target.source.config, target.source.filePath);
}
function deletePluginEntry(id, workingDirectory) {
const target = getPluginTarget(id, workingDirectory);
if (!target) {
throw codedError('Plugin entry not found', 'NOT_FOUND');
}
target.plugin.splice(target.index, 1);
if (target.plugin.length === 0) {
delete target.source.config.plugin;
}
writeConfig(target.source.config, target.source.filePath);
}
function listPluginDirFiles(workingDirectory) {
const scopes = [AGENT_SCOPE.USER];
if (workingDirectory) {
scopes.push(AGENT_SCOPE.PROJECT);
}
return scopes.flatMap((scope) => {
const dir = pluginDirForScope(scope, workingDirectory);
if (!fs.existsSync(dir)) {
return [];
}
return fs.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && PLUGIN_FILE_NAME_PATTERN.test(entry.name) && !entry.name.includes('..'))
.map((entry) => ({
id: encodePluginId('file', `${scope}:${entry.name}`),
fileName: entry.name,
scope,
kind: 'file',
absolutePath: path.join(dir, entry.name),
}));
});
}
function readPluginDirFile(id, workingDirectory) {
const target = fileTargetFromId(id, workingDirectory);
if (!fs.existsSync(target.absolutePath)) {
return null;
}
return {
fileName: target.fileName,
scope: target.scope,
content: fs.readFileSync(target.absolutePath, 'utf8'),
};
}
function writePluginDirFile(file, workingDirectory, opts = {}) {
const fileName = validateFileName(file?.fileName);
const scope = file?.scope || AGENT_SCOPE.USER;
validateScope(scope);
const dir = pluginDirForScope(scope, workingDirectory);
const absolutePath = path.join(dir, fileName);
if (!opts.overwrite && fs.existsSync(absolutePath)) {
throw codedError(`Plugin file "${fileName}" already exists`, 'FILE_EXISTS');
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(absolutePath, file?.content ?? '', 'utf8');
}
function deletePluginDirFile(id, workingDirectory) {
const target = fileTargetFromId(id, workingDirectory);
if (!fs.existsSync(target.absolutePath)) {
throw codedError(`Plugin file "${target.fileName}" not found`, 'NOT_FOUND');
}
fs.unlinkSync(target.absolutePath);
}
export {
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
parsePluginRaw,
serializePluginEntry,
};
@@ -0,0 +1,176 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
let rootDir;
let projectDir;
let userConfigPath;
let plugins;
function thrownBy(fn) {
try {
fn();
} catch (error) {
return error;
}
throw new Error('Expected function to throw');
}
function writeJson(filePath, data) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
describe('opencode plugins data layer', () => {
beforeAll(async () => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-plugins-'));
userConfigPath = path.join(rootDir, 'user-opencode.json');
process.env.OPENCODE_CONFIG = userConfigPath;
plugins = await import('./plugins.js');
});
beforeEach(() => {
process.env.OPENCODE_CONFIG = userConfigPath;
projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'));
fs.rmSync(userConfigPath, { force: true });
});
afterAll(() => {
fs.rmSync(rootDir, { recursive: true, force: true });
delete process.env.OPENCODE_CONFIG;
});
test('parses raw plugin entries', () => {
expect(plugins.parsePluginRaw('foo')).toEqual({ spec: 'foo' });
expect(plugins.parsePluginRaw('foo@1.0.0')).toEqual({ spec: 'foo@1.0.0' });
expect(plugins.parsePluginRaw(['foo', { a: 1 }])).toEqual({ spec: 'foo', options: { a: 1 } });
expect(plugins.parsePluginRaw(['foo', {}])).toEqual({ spec: 'foo', options: {} });
expect(() => plugins.parsePluginRaw(123)).toThrow('Plugin spec');
});
test('serializes plugin entries', () => {
expect(plugins.serializePluginEntry({ spec: 'foo' })).toBe('foo');
expect(plugins.serializePluginEntry({ spec: 'foo', options: undefined })).toBe('foo');
expect(plugins.serializePluginEntry({ spec: 'foo', options: {} })).toBe('foo');
expect(plugins.serializePluginEntry({ spec: 'foo', options: { a: 1 } })).toEqual(['foo', { a: 1 }]);
});
test('rejects invalid specs and file names', () => {
expect(() => plugins.createPluginEntry({ spec: 123, scope: 'user' }, projectDir)).toThrow('Plugin spec');
expect(() => plugins.writePluginDirFile({ fileName: '', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
expect(() => plugins.writePluginDirFile({ fileName: '../bad.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
expect(() => plugins.writePluginDirFile({ fileName: 'a/b.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
expect(() => plugins.writePluginDirFile({ fileName: 'A.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
expect(() => plugins.writePluginDirFile({ fileName: 'foo.txt', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
});
test('creates string and tuple entries with duplicate rejection', () => {
plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir);
plugins.createPluginEntry({ spec: 'tuple-plugin', options: { apiKey: 'x' }, scope: 'user' }, projectDir);
expect(readJson(userConfigPath).plugin).toEqual(['plain-plugin', ['tuple-plugin', { apiKey: 'x' }]]);
expect(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir)).toThrow('already exists');
expect(thrownBy(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir))).toHaveProperty('code', 'ENTRY_EXISTS');
});
test('routes project entries to project config and user entries to custom user config', () => {
plugins.createPluginEntry({ spec: 'user-plugin', scope: 'user' }, projectDir);
plugins.createPluginEntry({ spec: 'project-plugin', scope: 'project' }, projectDir);
expect(readJson(userConfigPath).plugin).toEqual(['user-plugin']);
expect(readJson(path.join(projectDir, '.opencode', 'opencode.json')).plugin).toEqual(['project-plugin']);
});
test('re-resolves custom config env between calls', () => {
const firstConfigPath = path.join(rootDir, 'first', 'opencode.json');
const secondConfigPath = path.join(rootDir, 'second', 'opencode.json');
process.env.OPENCODE_CONFIG = firstConfigPath;
plugins.createPluginEntry({ spec: 'first-plugin', scope: 'user' }, projectDir);
plugins.writePluginDirFile({ fileName: 'first.js', content: 'one', scope: 'user' }, projectDir);
process.env.OPENCODE_CONFIG = secondConfigPath;
plugins.createPluginEntry({ spec: 'second-plugin', scope: 'user' }, projectDir);
plugins.writePluginDirFile({ fileName: 'second.js', content: 'two', scope: 'user' }, projectDir);
expect(readJson(firstConfigPath).plugin).toEqual(['first-plugin']);
expect(readJson(secondConfigPath).plugin).toEqual(['second-plugin']);
expect(fs.existsSync(path.join(path.dirname(firstConfigPath), 'plugins', 'first.js'))).toBe(true);
expect(fs.existsSync(path.join(path.dirname(secondConfigPath), 'plugins', 'second.js'))).toBe(true);
});
test('updates entries in place and transitions between string and tuple', () => {
writeJson(userConfigPath, { plugin: ['first', ['second', { a: 1 }], 'third'] });
plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:second'), { spec: 'second-new', options: {} }, projectDir);
expect(readJson(userConfigPath).plugin).toEqual(['first', 'second-new', 'third']);
plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:first'), { spec: 'first-new', options: { b: 2 } }, projectDir);
expect(readJson(userConfigPath).plugin).toEqual([['first-new', { b: 2 }], 'second-new', 'third']);
});
test('deletes entries and prunes empty plugin key', () => {
writeJson(userConfigPath, { plugin: ['only'] });
plugins.deletePluginEntry(plugins.encodePluginId('config', 'user:only'), projectDir);
expect(readJson(userConfigPath)).toEqual({});
});
test('lists entries from user and project layers with scopes and parsed kinds', () => {
writeJson(userConfigPath, { plugin: ['npm-plugin', '/abs/plugin.js', '@scope/pkg@1.0.0'] });
writeJson(path.join(projectDir, '.opencode', 'opencode.json'), { plugin: ['./local-plugin.js'] });
const entries = plugins.listPluginEntries(projectDir);
expect(entries).toEqual([
expect.objectContaining({ spec: 'npm-plugin', scope: 'user', kind: 'config', parsedKind: 'npm', sourcePath: userConfigPath }),
expect.objectContaining({ spec: '/abs/plugin.js', scope: 'user', kind: 'config', parsedKind: 'path', sourcePath: userConfigPath }),
expect.objectContaining({ spec: '@scope/pkg@1.0.0', scope: 'user', kind: 'config', parsedKind: 'npm', sourcePath: userConfigPath }),
expect.objectContaining({ spec: './local-plugin.js', scope: 'project', kind: 'config', parsedKind: 'path', sourcePath: path.join(projectDir, '.opencode', 'opencode.json') }),
]);
fs.rmSync(userConfigPath, { force: true });
expect(plugins.listPluginEntries(projectDir)).toEqual([
expect.objectContaining({ spec: './local-plugin.js', scope: 'project' }),
]);
});
test('encodes and decodes ids', () => {
const id = plugins.encodePluginId('config', 'user:oh-my-openagent@4.3.0');
expect(plugins.decodePluginId(id)).toEqual({ prefix: 'config', value: 'user:oh-my-openagent@4.3.0' });
});
test('round-trips plugin dir files', () => {
plugins.writePluginDirFile({ fileName: 'my-plugin.ts', content: 'export default {}', scope: 'project' }, projectDir);
const file = plugins.listPluginDirFiles(projectDir).find((candidate) => candidate.fileName === 'my-plugin.ts');
expect(file).toEqual(expect.objectContaining({ fileName: 'my-plugin.ts', scope: 'project', kind: 'file' }));
expect(plugins.readPluginDirFile(file.id, projectDir)).toEqual({ fileName: 'my-plugin.ts', scope: 'project', content: 'export default {}' });
plugins.deletePluginDirFile(file.id, projectDir);
expect(plugins.listPluginDirFiles(projectDir).filter((candidate) => candidate.scope === 'project')).toEqual([]);
expect(() => plugins.deletePluginDirFile(file.id, projectDir)).toThrow('not found');
});
test('rejects duplicate plugin dir files unless overwrite is true', () => {
plugins.writePluginDirFile({ fileName: 'dup.js', content: 'one', scope: 'project' }, projectDir);
expect(() => plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir)).toThrow('already exists');
expect(thrownBy(() => plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir))).toHaveProperty('code', 'FILE_EXISTS');
plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir, { overwrite: true });
expect(fs.readFileSync(path.join(projectDir, '.opencode', 'plugins', 'dup.js'), 'utf8')).toBe('two');
});
test('lists only valid plugin dir files', () => {
const dir = path.join(projectDir, '.opencode', 'plugins');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'valid.mjs'), '', 'utf8');
fs.writeFileSync(path.join(dir, 'README.md'), '', 'utf8');
expect(plugins.listPluginDirFiles(projectDir).filter((file) => file.scope === 'project')).toEqual([
expect.objectContaining({ fileName: 'valid.mjs', scope: 'project' }),
]);
});
});