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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
a25e64099c
commit
2b47d899c6
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user