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
@@ -0,0 +1,381 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { PluginEntry, PluginFile, RegistryResult } from './usePluginsStore';
const activeProjectPath = '/workspace/project';
const refreshAfterOpenCodeRestartMock = mock(async () => undefined);
const startConfigUpdateMock = mock(() => undefined);
const finishConfigUpdateMock = mock(() => undefined);
mock.module('@/stores/useProjectsStore', () => ({
useProjectsStore: {
getState: () => ({
getActiveProject: () => ({ path: activeProjectPath }),
}),
},
}));
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
getDirectory: () => '/fallback/project',
},
}));
mock.module('@/stores/useAgentsStore', () => ({
refreshAfterOpenCodeRestart: refreshAfterOpenCodeRestartMock,
}));
mock.module('@/lib/configUpdate', () => ({
startConfigUpdate: startConfigUpdateMock,
finishConfigUpdate: finishConfigUpdateMock,
}));
const { usePluginsStore } = await import('./usePluginsStore');
const entry: PluginEntry = {
id: 'config:user:plugin-a',
spec: 'plugin-a',
scope: 'user',
kind: 'config',
parsedKind: 'npm',
};
const file: PluginFile = {
id: 'file:user:plugin.ts',
fileName: 'plugin.ts',
scope: 'user',
kind: 'file',
};
const pluginListPayload = {
entries: [entry],
files: [file],
};
const okMutationPayload = {
success: true,
requiresReload: false,
message: 'ok',
reloadDelayMs: 800,
reloadFailed: false,
};
const registryOk: RegistryResult = {
kind: 'npm-ok',
spec: 'plugin-a',
name: 'plugin-a',
currentVersion: null,
latestVersion: '1.0.0',
versions: ['1.0.0'],
hasUpdate: false,
};
const jsonResponse = (body: unknown, init?: ResponseInit): Response =>
new Response(JSON.stringify(body), {
status: init?.status ?? 200,
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
});
type FetchCall = {
input: RequestInfo | URL;
init?: RequestInit;
};
const fetchCalls: FetchCall[] = [];
let queuedResponses: Response[] = [];
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
fetchCalls.push({ input, init });
return queuedResponses.shift() ?? jsonResponse(pluginListPayload);
});
const queueFetchResponses = (responses: Response[]) => {
queuedResponses = [...responses];
};
const resetStore = () => {
usePluginsStore.setState({
entries: [],
files: [],
selectedId: null,
isLoading: false,
registryInfo: {},
isLoadingRegistry: false,
draft: null,
});
};
const registryCalls = (): FetchCall[] => fetchCalls.filter((call) => String(call.input).includes('/api/config/plugins/registry'));
const requestBody = (callIndex: number): unknown => {
const init = fetchCalls[callIndex]?.init;
return init?.body ? JSON.parse(String(init.body)) : undefined;
};
describe('usePluginsStore', () => {
beforeEach(() => {
resetStore();
fetchCalls.length = 0;
queuedResponses = [];
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
test('loadPlugins calls config plugins endpoint once and populates entries/files', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
const result = await usePluginsStore.getState().loadPlugins();
expect(result).toBe(true);
expect(fetchCalls).toHaveLength(2);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins?directory=%2Fworkspace%2Fproject');
expect(usePluginsStore.getState().entries).toEqual([entry]);
expect(usePluginsStore.getState().files).toEqual([file]);
expect(usePluginsStore.getState().isLoading).toBe(false);
});
test('second loadPlugins within TTL reuses cached store data', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
await usePluginsStore.getState().loadPlugins();
await usePluginsStore.getState().loadPlugins();
expect(fetchCalls).toHaveLength(2);
});
test('force loadPlugins bypasses TTL cache', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] }), jsonResponse(pluginListPayload)]);
await usePluginsStore.getState().loadPlugins();
await usePluginsStore.getState().loadPlugins({ force: true });
expect(fetchCalls).toHaveLength(3);
});
test('createEntry posts spec and scope in request body', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload)]);
const result = await usePluginsStore.getState().createEntry({ spec: 'a', scope: 'user' });
expect(result.ok).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins/entry?directory=%2Fworkspace%2Fproject');
expect(fetchCalls[0]?.init?.method).toBe('POST');
expect(requestBody(0)).toEqual({ spec: 'a', scope: 'user' });
});
test('createEntry includes options when provided', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload)]);
await usePluginsStore.getState().createEntry({ spec: 'a', options: { enabled: true }, scope: 'project' });
expect(requestBody(0)).toEqual({ spec: 'a', options: { enabled: true }, scope: 'project' });
});
test('updateEntry patches entry id path', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload)]);
const result = await usePluginsStore.getState().updateEntry('entry-id', { spec: 'b' });
expect(result.ok).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins/entry/entry-id?directory=%2Fworkspace%2Fproject');
expect(fetchCalls[0]?.init?.method).toBe('PATCH');
expect(requestBody(0)).toEqual({ spec: 'b' });
});
test('deleteEntry deletes entry id, invalidates cache, reloads, and clears selected id', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] }), jsonResponse(okMutationPayload), jsonResponse({ entries: [], files: [file] })]);
await usePluginsStore.getState().loadPlugins();
usePluginsStore.getState().setSelected(entry.id);
const result = await usePluginsStore.getState().deleteEntry(entry.id);
expect(result.ok).toBe(true);
expect(fetchCalls[2]?.input).toBe(`/api/config/plugins/entry/${encodeURIComponent(entry.id)}?directory=%2Fworkspace%2Fproject`);
expect(fetchCalls[2]?.init?.method).toBe('DELETE');
expect(fetchCalls[3]?.input).toBe('/api/config/plugins?directory=%2Fworkspace%2Fproject');
expect(usePluginsStore.getState().entries).toEqual([]);
expect(usePluginsStore.getState().selectedId).toBeNull();
});
test('createFile posts file name, content, and scope', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload)]);
const result = await usePluginsStore.getState().createFile({ fileName: 'plugin.ts', content: 'export {}', scope: 'user' });
expect(result.ok).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins/file?directory=%2Fworkspace%2Fproject');
expect(fetchCalls[0]?.init?.method).toBe('POST');
expect(requestBody(0)).toEqual({ fileName: 'plugin.ts', content: 'export {}', scope: 'user' });
});
test('failed mutation returns ok false and leaves plugins unchanged', async () => {
usePluginsStore.setState({ entries: [entry], files: [file] });
queueFetchResponses([jsonResponse({ error: 'boom' }, { status: 500 })]);
const result = await usePluginsStore.getState().createEntry({ spec: 'bad', scope: 'user' });
expect(result).toEqual({ ok: false });
expect(usePluginsStore.getState().entries).toEqual([entry]);
expect(usePluginsStore.getState().files).toEqual([file]);
});
test('getById returns entries and files by id', () => {
usePluginsStore.setState({ entries: [entry], files: [file] });
expect(usePluginsStore.getState().getById(entry.id)).toEqual(entry);
expect(usePluginsStore.getState().getById(file.id)).toEqual(file);
});
test('readFile fetches plugin file content', async () => {
queueFetchResponses([jsonResponse({ fileName: 'plugin.ts', scope: 'user', content: 'export {}' })]);
const result = await usePluginsStore.getState().readFile(file.id);
expect(fetchCalls[0]?.input).toBe(`/api/config/plugins/file/${encodeURIComponent(file.id)}?directory=%2Fworkspace%2Fproject`);
expect(result).toEqual({ fileName: 'plugin.ts', scope: 'user', content: 'export {}' });
});
test('loadRegistryInfo derives specs from entries and stores registry results', async () => {
usePluginsStore.setState({ entries: [{ ...entry, spec: 'foo@1' }] });
queueFetchResponses([
jsonResponse({
results: [{ kind: 'npm-ok', spec: 'foo@1', name: 'foo', currentVersion: '1', latestVersion: '2', hasUpdate: true, versions: ['1', '2'] }],
}),
]);
await usePluginsStore.getState().loadRegistryInfo();
expect(String(fetchCalls[0]?.input)).toContain('specs=foo%401');
expect(usePluginsStore.getState().registryInfo['foo@1']?.kind).toBe('npm-ok');
expect(usePluginsStore.getState().isLoadingRegistry).toBe(false);
});
test('loadRegistryInfo force adds refresh flag', async () => {
queueFetchResponses([jsonResponse({ results: [] })]);
await usePluginsStore.getState().loadRegistryInfo({ specs: ['foo@1'], force: true });
expect(String(fetchCalls[0]?.input)).toContain('refresh=true');
});
test('loadRegistryInfo accepts explicit comma-joined specs', async () => {
queueFetchResponses([jsonResponse({ results: [] })]);
await usePluginsStore.getState().loadRegistryInfo({ specs: ['x@1', 'y@2'] });
expect(String(fetchCalls[0]?.input)).toContain('specs=x%401,y%402');
});
test('loadRegistryInfo skips empty specs and clears loading flag', async () => {
usePluginsStore.setState({ isLoadingRegistry: true });
await usePluginsStore.getState().loadRegistryInfo({ specs: [] });
expect(fetchCalls).toHaveLength(0);
expect(usePluginsStore.getState().isLoadingRegistry).toBe(false);
});
test('loadPlugins success triggers registry load without blocking result', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
const result = await usePluginsStore.getState().loadPlugins();
expect(result).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins?directory=%2Fworkspace%2Fproject');
expect(registryCalls()).toHaveLength(1);
});
test('createEntry success refreshes registry for new spec with force', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
const result = await usePluginsStore.getState().createEntry({ spec: 'new-plugin@1', scope: 'user' });
expect(result.ok).toBe(true);
expect(registryCalls()).toHaveLength(1);
expect(String(registryCalls()[0]?.input)).toContain('specs=new-plugin%401');
expect(String(registryCalls()[0]?.input)).toContain('refresh=true');
});
test('updateEntry success refreshes changed spec with force', async () => {
usePluginsStore.setState({ entries: [entry] });
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
const result = await usePluginsStore.getState().updateEntry(entry.id, { spec: 'plugin-b@2' });
expect(result.ok).toBe(true);
expect(registryCalls()).toHaveLength(1);
expect(String(registryCalls()[0]?.input)).toContain('specs=plugin-b%402');
expect(String(registryCalls()[0]?.input)).toContain('refresh=true');
});
test('updateEntry success refreshes existing spec when spec unchanged', async () => {
usePluginsStore.setState({ entries: [entry] });
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
const result = await usePluginsStore.getState().updateEntry(entry.id, { options: { enabled: true } });
expect(result.ok).toBe(true);
expect(String(registryCalls()[0]?.input)).toContain('specs=plugin-a');
});
test('deleteEntry success removes deleted spec from registryInfo', async () => {
usePluginsStore.setState({ entries: [entry], registryInfo: { [entry.spec]: registryOk } });
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse({ entries: [], files: [] })]);
const result = await usePluginsStore.getState().deleteEntry(entry.id);
expect(result.ok).toBe(true);
expect(usePluginsStore.getState().registryInfo[entry.spec]).toBe(undefined);
});
test('updateToLatest updates npm-ok entry to latest version', async () => {
usePluginsStore.setState({
entries: [{ ...entry, id: 'X', spec: 'foo@1' }],
registryInfo: {
'foo@1': { kind: 'npm-ok', spec: 'foo@1', name: 'foo', currentVersion: '1', latestVersion: '2', versions: ['1', '2'], hasUpdate: true },
},
});
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
const result = await usePluginsStore.getState().updateToLatest('X');
expect(result.ok).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins/entry/X?directory=%2Fworkspace%2Fproject');
expect(requestBody(0)).toEqual({ spec: 'foo@2' });
});
test('updateToLatest returns ok false when hasUpdate is false', async () => {
usePluginsStore.setState({ entries: [entry], registryInfo: { [entry.spec]: registryOk } });
const result = await usePluginsStore.getState().updateToLatest(entry.id);
expect(result).toEqual({ ok: false });
expect(fetchCalls).toHaveLength(0);
});
test('updateToLatest returns ok false for missing package registry result', async () => {
usePluginsStore.setState({
entries: [entry],
registryInfo: { [entry.spec]: { kind: 'npm-missing-package', spec: entry.spec, name: entry.spec, error: 'missing' } },
});
const result = await usePluginsStore.getState().updateToLatest(entry.id);
expect(result).toEqual({ ok: false });
expect(fetchCalls).toHaveLength(0);
});
test('loadRegistryInfo chunks long spec lists into multiple registry requests', async () => {
const entries = Array.from({ length: 50 }, (_, index): PluginEntry => ({
...entry,
id: `config:user:plugin-${index}`,
spec: `plugin-${index}-${'x'.repeat(20)}@1.0.0`,
}));
usePluginsStore.setState({ entries });
queueFetchResponses([jsonResponse({ results: [] }), jsonResponse({ results: [] })]);
await usePluginsStore.getState().loadRegistryInfo();
expect(registryCalls()).toHaveLength(2);
});
});
+467
View File
@@ -0,0 +1,467 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage';
import {
startConfigUpdate,
finishConfigUpdate,
} from '@/lib/configUpdate';
import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
export type PluginScope = 'user' | 'project';
export type PluginParsedKind = 'npm' | 'path';
export interface PluginEntry {
id: string;
spec: string;
options?: Record<string, unknown>;
scope: PluginScope;
kind: 'config';
parsedKind: PluginParsedKind;
}
export interface PluginFile {
id: string;
fileName: string;
scope: PluginScope;
kind: 'file';
}
export interface PluginDraft {
mode: 'entry' | 'file';
scope: PluginScope;
spec: string;
optionsJson: string;
fileName: string;
content: string;
}
export type PluginMutationResult = {
ok: boolean;
reloadFailed?: boolean;
message?: string;
warning?: string;
};
export type RegistryResult =
| { kind: 'npm-ok'; spec: string; name: string; currentVersion: string | null; latestVersion: string | null; versions: string[]; hasUpdate: boolean }
| { kind: 'npm-missing-version'; spec: string; name: string; currentVersion: string; latestVersion: string | null; versions: string[] }
| { kind: 'npm-missing-package'; spec: string; name: string; error: string }
| { kind: 'npm-malformed'; spec: string; error: string }
| { kind: 'npm-network'; spec: string; error: string }
| { kind: 'path-ok'; spec: string; absolutePath: string }
| { kind: 'path-missing'; spec: string; absolutePath: string }
| { kind: 'path-unreadable'; spec: string; absolutePath: string };
export interface PluginsStore {
entries: PluginEntry[];
files: PluginFile[];
selectedId: string | null;
isLoading: boolean;
registryInfo: Record<string, RegistryResult>;
isLoadingRegistry: boolean;
draft: PluginDraft | null;
setSelected: (id: string | null) => void;
setDraft: (draft: PluginDraft | null) => void;
loadPlugins: (options?: { force?: boolean }) => Promise<boolean>;
loadRegistryInfo: (opts?: { specs?: string[]; force?: boolean }) => Promise<void>;
updateToLatest: (id: string) => Promise<PluginMutationResult>;
createEntry: (input: { spec: string; options?: Record<string, unknown>; scope: PluginScope }) => Promise<PluginMutationResult>;
updateEntry: (id: string, input: { spec?: string; options?: Record<string, unknown> }) => Promise<PluginMutationResult>;
deleteEntry: (id: string) => Promise<PluginMutationResult>;
readFile: (id: string) => Promise<{ fileName: string; scope: PluginScope; content: string } | null>;
createFile: (input: { fileName: string; content: string; scope: PluginScope }) => Promise<PluginMutationResult>;
updateFile: (id: string, input: { content: string }) => Promise<PluginMutationResult>;
deleteFile: (id: string) => Promise<PluginMutationResult>;
getById: (id: string) => PluginEntry | PluginFile | undefined;
}
type PluginsListResponse = {
entries?: PluginEntry[];
files?: PluginFile[];
};
type RegistryInfoResponse = {
results?: RegistryResult[];
};
type PluginMutationPayload = {
success?: boolean;
requiresReload?: boolean;
message?: string;
reloadDelayMs?: number;
reloadFailed?: boolean;
warning?: string;
error?: string;
};
type PluginFileContent = {
fileName: string;
scope: PluginScope;
content: string;
};
const getConfigDirectory = (): string | null => {
try {
const projectsStore = useProjectsStore.getState();
const activeProject = projectsStore.getActiveProject?.();
if (activeProject?.path?.trim()) {
return activeProject.path.trim();
}
const clientDir = opencodeClient.getDirectory();
if (clientDir?.trim()) {
return clientDir.trim();
}
} catch (err) {
console.warn('[PluginsStore] Error resolving config directory:', err);
}
return null;
};
const CLIENT_RELOAD_DELAY_MS = 800;
export const PLUGINS_LOAD_CACHE_TTL_MS = 5000;
const DEFAULT_PLUGINS_CACHE_KEY = '__default__';
const pluginsLastLoadedAt = new Map<string, number>();
const pluginsLoadInFlight = new Map<string, Promise<boolean>>();
const REGISTRY_SPECS_CHUNK_LIMIT = 1500;
const getPluginCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_PLUGINS_CACHE_KEY;
};
const invalidatePluginCache = (directory: string | null) => {
pluginsLastLoadedAt.delete(getPluginCacheKey(directory));
};
export const usePluginsStore = create<PluginsStore>()(
devtools(
persist(
(set, get) => ({
entries: [],
files: [],
selectedId: null,
isLoading: false,
registryInfo: {},
isLoadingRegistry: false,
draft: null,
setSelected: (id) => set({ selectedId: id }),
setDraft: (draft) => set({ draft }),
loadPlugins: async (options) => {
const configDirectory = getConfigDirectory();
const cacheKey = getPluginCacheKey(configDirectory);
const now = Date.now();
const loadedAt = pluginsLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedPlugins = get().entries.length > 0 || get().files.length > 0;
if (!options?.force && hasCachedPlugins && now - loadedAt < PLUGINS_LOAD_CACHE_TTL_MS) {
return true;
}
const inFlight = pluginsLoadInFlight.get(cacheKey);
if (!options?.force && inFlight) {
return inFlight;
}
const request = (async () => {
set({ isLoading: true });
try {
const response = await fetch(buildPluginsUrl('/api/config/plugins', configDirectory), {
headers: buildDirectoryHeaders(configDirectory),
});
if (!response.ok) {
throw new Error('Failed to load plugins');
}
const data = await readJson<PluginsListResponse>(response);
set({ entries: data.entries ?? [], files: data.files ?? [], isLoading: false });
pluginsLastLoadedAt.set(cacheKey, Date.now());
if (!options?.force) {
void get().loadRegistryInfo();
}
return true;
} catch (error) {
console.error('[PluginsStore] Failed to load plugins:', error);
set({ isLoading: false });
return false;
}
})();
pluginsLoadInFlight.set(cacheKey, request);
try {
return await request;
} finally {
pluginsLoadInFlight.delete(cacheKey);
}
},
loadRegistryInfo: async (opts) => {
const specs = dedupeSpecs(opts?.specs ?? get().entries.map((entry) => entry.spec));
if (specs.length === 0) {
set({ isLoadingRegistry: false });
return;
}
set({ isLoadingRegistry: true });
try {
const configDirectory = getConfigDirectory();
const nextRegistryInfo: Record<string, RegistryResult> = { ...get().registryInfo };
for (const chunk of chunkSpecs(specs)) {
const response = await fetch(buildRegistryUrl(chunk, opts?.force === true, configDirectory), {
headers: buildDirectoryHeaders(configDirectory),
});
if (!response.ok) {
throw new Error('Failed to load plugin registry info');
}
const data = await readJson<RegistryInfoResponse>(response);
for (const result of data.results ?? []) {
nextRegistryInfo[result.spec] = result;
}
}
set({ registryInfo: nextRegistryInfo, isLoadingRegistry: false });
} catch (error) {
console.error('[PluginsStore] Failed to load plugin registry info:', error);
set({ isLoadingRegistry: false });
}
},
updateToLatest: async (id) => {
const entry = get().entries.find((plugin) => plugin.id === id);
if (!entry) return { ok: false };
const info = get().registryInfo[entry.spec];
if (!info || info.kind !== 'npm-ok' || !info.hasUpdate || !info.latestVersion) {
return { ok: false };
}
return await get().updateEntry(id, { spec: `${info.name}@${info.latestVersion}` });
},
createEntry: async (input) => {
const result = await runPluginMutation('Creating plugin entry…', async (configDirectory) => {
const response = await fetch(buildPluginsUrl('/api/config/plugins/entry', configDirectory), {
method: 'POST',
headers: buildJsonHeaders(configDirectory),
body: JSON.stringify(buildEntryBody(input)),
});
return response;
}, get);
if (result.ok) {
void get().loadRegistryInfo({ specs: [input.spec], force: true });
}
return result;
},
updateEntry: async (id, input) => {
const existingSpec = get().entries.find((plugin) => plugin.id === id)?.spec;
const nextSpec = input.spec ?? existingSpec;
const result = await runPluginMutation('Updating plugin entry…', async (configDirectory) => {
const response = await fetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), {
method: 'PATCH',
headers: buildJsonHeaders(configDirectory),
body: JSON.stringify(buildEntryBody(input)),
});
return response;
}, get);
if (result.ok && nextSpec) {
void get().loadRegistryInfo({ specs: [nextSpec], force: true });
}
return result;
},
deleteEntry: async (id) => {
const entryToDelete = get().entries.find((plugin) => plugin.id === id);
const result = await runPluginMutation('Deleting plugin entry…', async (configDirectory) => {
const response = await fetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), {
method: 'DELETE',
headers: buildDirectoryHeaders(configDirectory),
});
return response;
}, get);
if (result.ok && get().selectedId === id) {
set({ selectedId: null });
}
if (result.ok && entryToDelete) {
const nextRegistryInfo = { ...get().registryInfo };
delete nextRegistryInfo[entryToDelete.spec];
set({ registryInfo: nextRegistryInfo });
}
return result;
},
readFile: async (id) => {
try {
const configDirectory = getConfigDirectory();
const response = await fetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
headers: buildDirectoryHeaders(configDirectory),
});
if (!response.ok) {
throw new Error('Failed to read plugin file');
}
return await readJson<PluginFileContent>(response);
} catch (error) {
console.error('[PluginsStore] Failed to read plugin file:', error);
return null;
}
},
createFile: async (input) => {
return runPluginMutation('Creating plugin file…', async (configDirectory) => {
const response = await fetch(buildPluginsUrl('/api/config/plugins/file', configDirectory), {
method: 'POST',
headers: buildJsonHeaders(configDirectory),
body: JSON.stringify(input),
});
return response;
}, get);
},
updateFile: async (id, input) => {
return runPluginMutation('Updating plugin file…', async (configDirectory) => {
const response = await fetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
method: 'PUT',
headers: buildJsonHeaders(configDirectory),
body: JSON.stringify(input),
});
return response;
}, get);
},
deleteFile: async (id) => {
const result = await runPluginMutation('Deleting plugin file…', async (configDirectory) => {
const response = await fetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
method: 'DELETE',
headers: buildDirectoryHeaders(configDirectory),
});
return response;
}, get);
if (result.ok && get().selectedId === id) {
set({ selectedId: null });
}
return result;
},
getById: (id) => {
return get().entries.find((plugin) => plugin.id === id) ?? get().files.find((plugin) => plugin.id === id);
},
}),
{
name: 'plugins-store',
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({ selectedId: state.selectedId }),
},
),
{ name: 'plugins-store' },
),
);
function buildPluginsUrl(path: string, directory: string | null): string {
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
return `${path}${queryParams}`;
}
function buildRegistryUrl(specs: string[], force: boolean, directory: string | null): string {
const params = new URLSearchParams();
if (force) params.set('refresh', 'true');
if (directory) params.set('directory', directory);
const suffix = params.toString();
const specsParam = `specs=${specs.map(encodeURIComponent).join(',')}`;
return `/api/config/plugins/registry?${specsParam}${suffix ? `&${suffix}` : ''}`;
}
function dedupeSpecs(specs: string[]): string[] {
return Array.from(new Set(specs.map((spec) => spec.trim()).filter(Boolean)));
}
function chunkSpecs(specs: string[]): string[][] {
const chunks: string[][] = [];
let current: string[] = [];
let currentLength = 0;
for (const spec of specs) {
const encodedSpec = encodeURIComponent(spec);
const nextLength = current.length === 0 ? encodedSpec.length : currentLength + 1 + encodedSpec.length;
if (current.length > 0 && nextLength > REGISTRY_SPECS_CHUNK_LIMIT) {
chunks.push(current);
current = [spec];
currentLength = encodedSpec.length;
} else {
current.push(spec);
currentLength = nextLength;
}
}
if (current.length > 0) {
chunks.push(current);
}
return chunks;
}
function buildDirectoryHeaders(directory: string | null): HeadersInit | undefined {
return directory ? { 'x-opencode-directory': directory } : undefined;
}
function buildJsonHeaders(directory: string | null): HeadersInit {
return {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
};
}
function buildEntryBody(input: { spec?: string; options?: Record<string, unknown>; scope?: PluginScope }): Record<string, unknown> {
const body: Record<string, unknown> = {};
if (input.spec !== undefined) body.spec = input.spec;
if (input.options !== undefined) body.options = input.options;
if (input.scope !== undefined) body.scope = input.scope;
return body;
}
async function runPluginMutation(
progressMessage: string,
request: (configDirectory: string | null) => Promise<Response>,
get: () => PluginsStore,
): Promise<PluginMutationResult> {
startConfigUpdate(progressMessage);
let requiresReload = false;
try {
const configDirectory = getConfigDirectory();
const response = await request(configDirectory);
const payload = await readJson<PluginMutationPayload | null>(response).catch(() => null);
if (!response.ok) {
throw new Error(payload?.error || 'Failed to update plugin configuration');
}
invalidatePluginCache(configDirectory);
if (payload?.requiresReload) {
requiresReload = true;
await refreshAfterOpenCodeRestart({
message: payload.message,
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
scopes: ['all'],
});
}
await get().loadPlugins({ force: true });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
} catch (error) {
console.error('[PluginsStore] Failed to update plugin configuration:', error);
return { ok: false };
} finally {
if (!requiresReload) finishConfigUpdate();
}
}
async function readJson<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}