* 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>
394 lines
13 KiB
JavaScript
394 lines
13 KiB
JavaScript
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,
|
|
};
|