Files
openchamber/packages/ui/src/stores/usePluginsStore.ts
T
Serhii DziupinandSerhii Dziupin af380081c2 feat(settings): add third-party integrations dashboard
Add a Settings → Integrations page for installing and managing the three
supported OpenCode provider plugins (Claude Code, Command Code, Cursor),
with search, i18n, and plugin-registry status wiring.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
2026-08-14 15:19:25 +00:00

493 lines
18 KiB
TypeScript

import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
import { startConfigUpdate } from '@/lib/configUpdate';
import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { noteDeferredRestartFromPayload } from '@/lib/opencode/deferredRestart';
export type PluginScope = 'user' | 'project';
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;
requiresManualRestart?: boolean;
restartDeferred?: boolean;
};
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<boolean>;
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;
requiresRestart?: boolean;
restartDeferred?: boolean;
requiresManualRestart?: 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;
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 runtimeFetch(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 true;
}
set({ isLoadingRegistry: true });
try {
const configDirectory = getConfigDirectory();
const nextRegistryInfo: Record<string, RegistryResult> = { ...get().registryInfo };
for (const chunk of chunkSpecs(specs)) {
const response = await runtimeFetch(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 });
return true;
} catch (error) {
console.error('[PluginsStore] Failed to load plugin registry info:', error);
set({ isLoadingRegistry: false });
return 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 runtimeFetch(buildPluginsUrl('/api/config/plugins/entry', configDirectory), {
method: 'POST',
headers: buildJsonHeaders(configDirectory),
body: JSON.stringify(buildEntryBody(input)),
});
return response;
}, get, { restartId: input.spec });
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 runtimeFetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), {
method: 'PATCH',
headers: buildJsonHeaders(configDirectory),
body: JSON.stringify(buildEntryBody(input)),
});
return response;
}, get, { restartId: id });
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 runtimeFetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), {
method: 'DELETE',
headers: buildDirectoryHeaders(configDirectory),
});
return response;
}, get, { restartId: id });
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 runtimeFetch(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 runtimeFetch(buildPluginsUrl('/api/config/plugins/file', configDirectory), {
method: 'POST',
headers: buildJsonHeaders(configDirectory),
body: JSON.stringify(input),
});
return response;
}, get, { restartId: input.fileName });
},
updateFile: async (id, input) => {
return runPluginMutation('Updating plugin file…', async (configDirectory) => {
const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
method: 'PUT',
headers: buildJsonHeaders(configDirectory),
body: JSON.stringify(input),
});
return response;
}, get, { restartId: id });
},
deleteFile: async (id) => {
const result = await runPluginMutation('Deleting plugin file…', async (configDirectory) => {
const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
method: 'DELETE',
headers: buildDirectoryHeaders(configDirectory),
});
return response;
}, get, { restartId: id });
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: createDeferredSafeJSONStorage(),
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,
options?: { restartId?: string },
): Promise<PluginMutationResult> {
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?.requiresManualRestart) {
await get().loadPlugins({ force: true });
return {
ok: true,
requiresManualRestart: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
}
if (noteDeferredRestartFromPayload(payload, 'plugins', { id: options?.restartId })) {
await get().loadPlugins({ force: true });
return {
ok: true,
restartDeferred: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
}
if (payload?.requiresReload) {
startConfigUpdate(progressMessage);
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 };
}
}
async function readJson<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}