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; 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; isLoadingRegistry: boolean; draft: PluginDraft | null; setSelected: (id: string | null) => void; setDraft: (draft: PluginDraft | null) => void; loadPlugins: (options?: { force?: boolean }) => Promise; loadRegistryInfo: (opts?: { specs?: string[]; force?: boolean }) => Promise; updateToLatest: (id: string) => Promise; createEntry: (input: { spec: string; options?: Record; scope: PluginScope }) => Promise; updateEntry: (id: string, input: { spec?: string; options?: Record }) => Promise; deleteEntry: (id: string) => Promise; readFile: (id: string) => Promise<{ fileName: string; scope: PluginScope; content: string } | null>; createFile: (input: { fileName: string; content: string; scope: PluginScope }) => Promise; updateFile: (id: string, input: { content: string }) => Promise; deleteFile: (id: string) => Promise; 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(); const pluginsLoadInFlight = new Map>(); 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()( 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(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 = { ...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(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(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; scope?: PluginScope }): Record { const body: Record = {}; 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, get: () => PluginsStore, options?: { restartId?: string }, ): Promise { try { const configDirectory = getConfigDirectory(); const response = await request(configDirectory); const payload = await readJson(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(response: Response): Promise { return (await response.json()) as T; }