diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..ca83e731 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "plugins/starter"] + path = plugins/starter + url = https://github.com/cloudcli-ai/cloudcli-plugin-starter.git diff --git a/README.md b/README.md index fc985a5d..25880a89 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,24 @@
CloudCLI UI

Cloud CLI (aka Claude Code UI)

+

A desktop and mobile UI for Claude Code, Cursor CLI, Codex, and Gemini-CLI.
Use it locally or remotely to view your active projects and sessions from everywhere.

- -A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere. -

- CloudCLI Cloud · Discord · Bug Reports · Contributing + CloudCLI Cloud · Documentation · Discord · Bug Reports · Contributing

- Join our Discord + CloudCLI Cloud + Join our Discord +

siteboon%2Fclaudecodeui | Trendshift

English · 한국어 · 中文 · 日本語
+--- + ## Screenshots
diff --git a/examples/plugins/hello-world/README.md b/examples/plugins/hello-world/README.md deleted file mode 100644 index 1897ab5c..00000000 --- a/examples/plugins/hello-world/README.md +++ /dev/null @@ -1,227 +0,0 @@ -# Project Stats — example plugin - -Scans the currently selected project and shows file counts, lines of code, a file-type breakdown chart, largest files, and recently modified files. - -This is the example plugin that ships with Claude Code UI. It demonstrates all three plugin capabilities in a way that's immediately useful. See the sections below for the full plugin authoring guide. - -## How plugins work - -A plugin's UI is a plain ES module loaded directly into the host app — no iframe. Plugins can optionally declare a `server` entry in their manifest — a Node.js script that the host runs as a subprocess. The module calls the server through an `api.rpc()` helper (the host proxies the calls using its own auth token). - -``` -┌─────────────────────────────────────────────────────────┐ -│ Host server │ -│ │ -│ Lifecycle: │ -│ git clone / git pull Install & update │ -│ npm install Dependency setup │ -│ │ -│ Runtime: │ -│ GET /api/plugins List plugins │ -│ GET /api/plugins/:name/assets/* Serve static files │ -│ ALL /api/plugins/:name/rpc/* Proxy → subprocess │ -│ PUT /api/plugins/:name/enable Toggle + start/stop │ -│ DELETE /api/plugins/:name Uninstall + stop │ -│ │ -│ Plugin subprocess (server.js): │ -│ Runs as a child process with restricted env │ -│ Listens on random local port │ -│ Receives secrets via X-Plugin-Secret-* headers │ -└───────────┬─────────────────────────┬───────────────────┘ - │ serves static files │ proxies RPC -┌───────────▼─────────────────────────▼───────────────────┐ -│ Frontend (browser) │ -│ │ -│ Plugin module (index.js) │ -│ import(url) → mount(container, api) │ -│ api.context — theme / project / session │ -│ api.onContextChange — subscribe to changes │ -│ api.rpc(method, path, body) → Promise │ -└─────────────────────────────────────────────────────────┘ -``` - -## Plugin structure - -``` -my-plugin/ - manifest.json # Required — plugin metadata - index.js # Frontend entry point (ES module, mount/unmount exports) - server.js # Optional — backend entry point (runs as subprocess) - package.json # Optional — npm dependencies for server.js -``` - -All files in the plugin directory are accessible via `/api/plugins/:name/assets/`. - -## manifest.json - -```jsonc -{ - "name": "hello-world", // Unique id — alphanumeric, hyphens, underscores only - "displayName": "Hello World", // Shown in the UI - "version": "1.0.0", - "description": "Short description shown in settings.", - "author": "Your Name", - "icon": "Puzzle", // Lucide icon name (see available icons below) - "type": "module", // "module" (default) or "iframe" (legacy) - "slot": "tab", // Where the plugin appears — only "tab" is supported today - "entry": "index.js", // Frontend entry file, relative to plugin directory - "server": "server.js", // Optional — backend entry file, runs as Node.js subprocess - "permissions": [] // Reserved for future use -} -``` - -### Available icons - -`Puzzle` (default), `Box`, `Database`, `Globe`, `Terminal`, `Wrench`, `Zap`, `BarChart3`, `Folder`, `MessageSquare`, `GitBranch` - -## Installation - -**Manual:** Copy your plugin folder into `~/.claude-code-ui/plugins/`. - -**From git:** In Settings > Plugins, paste a git URL and click Install. The repo is cloned into the plugins directory. - ---- - -## Frontend — Module API - -The host dynamically imports your entry file and calls `mount(container, api)`. When the plugin tab is closed or the plugin is disabled, `unmount(container)` is called. - -```js -// index.js - -export function mount(container, api) { - // api.context — current snapshot: { theme, project, session } - // api.onContextChange(cb) — subscribe, returns an unsubscribe function - // api.rpc(method, path, body?) — call the plugin's server subprocess - - container.innerHTML = '

Hello!

'; - - const unsub = api.onContextChange((ctx) => { - container.style.background = ctx.theme === 'dark' ? '#111' : '#fff'; - }); - - container._cleanup = unsub; -} - -export function unmount(container) { - if (typeof container._cleanup === 'function') container._cleanup(); - container.innerHTML = ''; -} -``` - -### Context object - -```js -api.context // always up to date -// { -// theme: "dark" | "light", -// project: { name: string, path: string } | null, -// session: { id: string, title: string } | null, -// } -``` - -### RPC helper - -```js -// Calls /api/plugins/:name/rpc/hello via the host's authenticated fetch -const data = await api.rpc('GET', '/hello'); - -// With a JSON body -const result = await api.rpc('POST', '/echo', { greeting: 'hi' }); -``` - ---- - -## Backend — Server subprocess - -Plugins that need to make authenticated API calls, use npm packages, or run Node.js logic can declare a `"server"` entry in their manifest. The host manages the full lifecycle: - -### How it works - -1. When the plugin is enabled, the host spawns `node server.js` as a child process -2. The subprocess **must** print a JSON line to stdout: `{"ready": true, "port": 12345}` -3. The host records the port and proxies requests from `/api/plugins/:name/rpc/*` to it -4. When the plugin is disabled or uninstalled, the host sends SIGTERM to the process - -### Restricted environment - -The subprocess runs with a **minimal env** — only `PATH`, `HOME`, `NODE_ENV`, and `PLUGIN_NAME`. It does **not** inherit the host's API keys, database URLs, or other secrets from `process.env`. - -### Secrets - -Per-plugin secrets are stored in `~/.claude-code-ui/plugins.json` and injected as HTTP headers on every proxied request: - -```json -{ - "hello-world": { - "enabled": true, - "secrets": { - "apiKey": "sk-live-..." - } - } -} -``` - -The plugin's server receives these as `x-plugin-secret-apikey` headers — they are per-call, never stored in the subprocess env. - -### Example server.js - -```js -const http = require('http'); - -const server = http.createServer((req, res) => { - res.setHeader('Content-Type', 'application/json'); - - // Read host-injected secrets - const apiKey = req.headers['x-plugin-secret-apikey']; - - if (req.method === 'GET' && req.url === '/hello') { - res.end(JSON.stringify({ message: 'Hello!', hasApiKey: Boolean(apiKey) })); - return; - } - - res.writeHead(404); - res.end(JSON.stringify({ error: 'Not found' })); -}); - -// Listen on a random port and signal readiness -server.listen(0, '127.0.0.1', () => { - const { port } = server.address(); - console.log(JSON.stringify({ ready: true, port })); -}); -``` - ---- - -## Frontend — Mobile - -On desktop, each enabled plugin gets its own tab in the tab bar. On mobile, plugins are grouped under a single "More" button in the bottom navigation to save space. - -## Security - -### Frontend isolation - -Plugin modules run in the same JS context as the host app but have no access to auth tokens or internal state — only the `api` object passed to `mount`. They cannot make authenticated API calls directly; all server communication goes through `api.rpc()`, which the host proxies. - -### Server subprocess isolation - -The subprocess runs as a separate OS process with: - -- **Restricted env** — no host secrets inherited; only `PATH`, `HOME`, `NODE_ENV`, `PLUGIN_NAME` -- **Per-call secrets** — injected as HTTP headers by the host proxy, never stored in process env -- **Process boundary** — a crash in the plugin cannot crash the host -- **Auth stripping** — the host removes `authorization` and `cookie` headers before proxying - -The subprocess runs as the same OS user, so it has the same filesystem/network access. This matches the trust model of VS Code extensions, Grafana backend plugins, and Terraform providers — the user explicitly installs the plugin. - -### Install-time protections - -npm `postinstall` scripts are blocked during installation (`--ignore-scripts`). Plugins that need npm packages should ship pre-built or use packages that work without postinstall hooks. - -## Try it - -```bash -cp -r examples/plugins/hello-world ~/.claude-code-ui/plugins/ -``` - -Then open Settings > Plugins — "Project Stats" should appear. Enable it, select a project, and open its tab to see the stats. diff --git a/examples/plugins/hello-world/icon.svg b/examples/plugins/hello-world/icon.svg deleted file mode 100644 index 90a4e78a..00000000 --- a/examples/plugins/hello-world/icon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/examples/plugins/hello-world/index.html b/examples/plugins/hello-world/index.html deleted file mode 100644 index ea8bf1cf..00000000 --- a/examples/plugins/hello-world/index.html +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - Hello World Plugin - - - -

Hello from a plugin!

-

This tab is rendered inside a sandboxed iframe.

- -
-
Theme
-
Project
-
Session
-
- -

Server RPC

-

This calls the plugin's own Node.js server subprocess via the postMessage RPC bridge.

- - -
Click a button to make an RPC call...
- - - - diff --git a/examples/plugins/hello-world/index.js b/examples/plugins/hello-world/index.js deleted file mode 100644 index 48a9c4be..00000000 --- a/examples/plugins/hello-world/index.js +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Project Stats plugin — module entry point. - * - * api shape: - * api.context — current PluginContext snapshot - * api.onContextChange(cb) → unsubscribe — called on theme/project/session changes - * api.rpc(method, path, body?) → Promise — proxied to this plugin's server subprocess - */ - -const PALETTE = [ - '#6366f1','#22d3ee','#f59e0b','#10b981', - '#f43f5e','#a78bfa','#fb923c','#34d399', - '#60a5fa','#e879f9','#facc15','#4ade80', -]; - -function ensureAssets() { - if (document.getElementById('ps-font')) return; - const link = document.createElement('link'); - link.id = 'ps-font'; - link.rel = 'stylesheet'; - link.href = 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap'; - document.head.appendChild(link); - - const s = document.createElement('style'); - s.id = 'ps-styles'; - s.textContent = ` - @keyframes ps-grow { from { width: 0 } } - @keyframes ps-fadeup { from { opacity:0; transform:translateY(6px) } to { opacity:1; transform:translateY(0) } } - @keyframes ps-pulse { 0%,100% { opacity:.3 } 50% { opacity:.6 } } - .ps-bar { animation: ps-grow 0.75s cubic-bezier(.16,1,.3,1) both } - .ps-up { animation: ps-fadeup 0.4s ease both } - .ps-skel { animation: ps-pulse 1.6s ease infinite } - `; - document.head.appendChild(s); -} - -const MONO = "'JetBrains Mono', 'Fira Code', ui-monospace, monospace"; - -function fmt(b) { - if (b < 1024) return `${b}B`; - if (b < 1048576) return `${(b / 1024).toFixed(1)}KB`; - return `${(b / 1048576).toFixed(1)}MB`; -} - -function ago(ms) { - const s = Math.floor((Date.now() - ms) / 1000); - if (s < 60) return 'just now'; - if (s < 3600) return `${Math.floor(s / 60)}m ago`; - if (s < 86400) return `${Math.floor(s / 3600)}h ago`; - return `${Math.floor(s / 86400)}d ago`; -} - -function countUp(el, target, formatFn, duration = 900) { - const start = performance.now(); - function tick(now) { - const p = Math.min((now - start) / duration, 1); - const ease = 1 - (1 - p) ** 3; - el.textContent = formatFn(Math.round(target * ease)); - if (p < 1) requestAnimationFrame(tick); - } - requestAnimationFrame(tick); -} - -function v(dark) { - return dark ? { - bg: '#08080f', - surface: '#0e0e1a', - border: '#1a1a2c', - text: '#e2e0f0', - muted: '#52507a', - accent: '#fbbf24', - dim: 'rgba(251,191,36,0.1)', - } : { - bg: '#fafaf9', - surface: '#ffffff', - border: '#e8e6f0', - text: '#0f0e1a', - muted: '#9490b0', - accent: '#d97706', - dim: 'rgba(217,119,6,0.08)', - }; -} - -function skeletonRows(c, widths) { - return widths.map((w, i) => ` -
`).join(''); -} - -export function mount(container, api) { - ensureAssets(); - let cache = null; - - const root = document.createElement('div'); - Object.assign(root.style, { - height: '100%', overflowY: 'auto', boxSizing: 'border-box', - padding: '24px', fontFamily: MONO, - }); - container.appendChild(root); - - function render(ctx, stats) { - const c = v(ctx.theme === 'dark'); - root.style.background = c.bg; - root.style.color = c.text; - - if (!ctx.project) { - root.innerHTML = ` -
-
~/.projects/
-└── (none selected)
-
select a project
-
`; - return; - } - - if (!stats) { - root.innerHTML = ` -
-
${ctx.project.name}
-
${ctx.project.path}
-
-
- ${[0, 1, 2].map(i => ` -
- ${skeletonRows(c, [65])} - ${skeletonRows(c, [40])} -
`).join('')} -
-
- ${[75, 55, 38, 22, 14].map((w, i) => ` -
-
-
-
`).join('')} -
`; - return; - } - - const maxCount = stats.byExtension[0]?.[1] || 1; - - root.innerHTML = ` -
-
-
- ${ctx.project.name} -
-
${ctx.project.path}
-
- -
- -
- ${[ - ['files', stats.totalFiles, n => n.toLocaleString()], - ['lines', stats.totalLines, n => n.toLocaleString()], - ['total size', stats.totalSize, fmt], - ].map(([label, val, fmt], i) => ` -
-
0
-
${label}
-
`).join('')} -
- -
-
file types
- ${stats.byExtension.map(([ext, count], i) => ` -
-
${ext}
-
-
-
-
${count}
-
`).join('')} -
- -
- ${[ - ['largest files', stats.largest.map(f => [f.name, fmt(f.size)])], - ['recently modified', stats.recent.map(f => [f.name, ago(f.mtime)])], - ].map(([title, rows], ci) => ` -
-
${title}
- ${rows.map(([name, val], ri) => ` -
-
${name}
-
${val}
-
`).join('')} -
`).join('')} -
- `; - - // Count-up animations for metric cards - [ - [0, stats.totalFiles, n => n.toLocaleString()], - [1, stats.totalLines, n => n.toLocaleString()], - [2, stats.totalSize, fmt], - ].forEach(([i, val, formatFn]) => { - const el = root.querySelector(`#ps-m-${i}`); - if (el) countUp(el, val, formatFn); - }); - - root.querySelector('#ps-refresh')?.addEventListener('click', () => { - cache = null; - load(api.context); - }); - } - - async function load(ctx) { - if (!ctx.project) { cache = null; render(ctx, null); return; } - render(ctx, null); - try { - const stats = await api.rpc('GET', `stats?path=${encodeURIComponent(ctx.project.path)}`); - cache = { projectPath: ctx.project.path, stats }; - render(ctx, stats); - } catch (err) { - const c = v(ctx.theme === 'dark'); - root.style.background = c.bg; - root.innerHTML = ` -
- ✗ ${err.message} -
`; - } - } - - load(api.context); - - const unsubscribe = api.onContextChange(ctx => { - if (ctx.project?.path === cache?.projectPath) render(ctx, cache?.stats ?? null); - else load(ctx); - }); - - container._psUnsubscribe = unsubscribe; -} - -export function unmount(container) { - if (typeof container._psUnsubscribe === 'function') { - container._psUnsubscribe(); - delete container._psUnsubscribe; - } - container.innerHTML = ''; -} diff --git a/examples/plugins/hello-world/manifest.json b/examples/plugins/hello-world/manifest.json deleted file mode 100644 index 930a1141..00000000 --- a/examples/plugins/hello-world/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "project-stats", - "displayName": "Project Stats", - "version": "1.0.0", - "description": "Scans the current project and shows file counts, lines of code, file-type breakdown, largest files, and recently modified files.", - "author": "CloudCLI UI", - "icon": "icon.svg", - "type": "module", - "slot": "tab", - "entry": "index.js", - "server": "server.js", - "permissions": [] -} diff --git a/examples/plugins/hello-world/server.js b/examples/plugins/hello-world/server.js deleted file mode 100644 index d800fbf7..00000000 --- a/examples/plugins/hello-world/server.js +++ /dev/null @@ -1,105 +0,0 @@ -const http = require('http'); -const fs = require('fs'); -const path = require('path'); - -const TEXT_EXTS = new Set([ - '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte', '.astro', - '.css', '.scss', '.sass', '.less', - '.html', '.htm', '.xml', '.svg', - '.json', '.yaml', '.yml', '.toml', '.ini', - '.md', '.mdx', '.txt', '.rst', - '.py', '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp', '.cs', - '.sh', '.bash', '.zsh', '.fish', - '.sql', '.graphql', '.gql', -]); - -const SKIP_DIRS = new Set([ - 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', - 'coverage', '.cache', '__pycache__', '.venv', 'venv', - 'target', 'vendor', '.turbo', 'out', '.output', 'tmp', -]); - -function scan(dir, max = 5000) { - const files = []; - (function walk(d, depth) { - if (depth > 6 || files.length >= max) return; - let entries; - try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; } - for (const e of entries) { - if (files.length >= max) break; - if (e.name.startsWith('.') && e.name !== '.env') continue; - const full = path.join(d, e.name); - if (e.isDirectory()) { - if (!SKIP_DIRS.has(e.name)) walk(full, depth + 1); - } else if (e.isFile()) { - try { - const stat = fs.statSync(full); - files.push({ - full, - rel: path.relative(dir, full), - ext: path.extname(e.name).toLowerCase() || '(none)', - size: stat.size, - mtime: stat.mtimeMs, - }); - } catch { /* skip unreadable */ } - } - } - })(dir, 0); - return files; -} - -function countLines(full, size) { - if (size > 256 * 1024) return 0; // skip large files - try { return (fs.readFileSync(full, 'utf-8').match(/\n/g) || []).length + 1; } - catch { return 0; } -} - -function getStats(projectPath) { - if (!projectPath || !path.isAbsolute(projectPath)) throw new Error('Invalid path'); - if (!fs.existsSync(projectPath)) throw new Error('Path does not exist'); - - const files = scan(projectPath); - const byExt = {}; - let totalLines = 0; - let totalSize = 0; - - for (const f of files) { - byExt[f.ext] = (byExt[f.ext] || 0) + 1; - totalSize += f.size; - if (TEXT_EXTS.has(f.ext)) totalLines += countLines(f.full, f.size); - } - - return { - totalFiles: files.length, - totalLines, - totalSize, - byExtension: Object.entries(byExt).sort((a, b) => b[1] - a[1]).slice(0, 12), - largest: [...files].sort((a, b) => b.size - a.size).slice(0, 6).map(f => ({ name: f.rel, size: f.size })), - recent: [...files].sort((a, b) => b.mtime - a.mtime).slice(0, 6).map(f => ({ name: f.rel, mtime: f.mtime })), - }; -} - -const server = http.createServer((req, res) => { - res.setHeader('Content-Type', 'application/json'); - - if (req.method === 'GET' && req.url.startsWith('/stats')) { - try { - const { searchParams } = new URL(req.url, 'http://localhost'); - const stats = getStats(searchParams.get('path')); - res.end(JSON.stringify(stats)); - } catch (err) { - res.writeHead(400); - res.end(JSON.stringify({ error: err.message })); - } - return; - } - - res.writeHead(404); - res.end(JSON.stringify({ error: 'Not found' })); -}); - -server.listen(0, '127.0.0.1', () => { - const { port } = server.address(); - // Signal readiness to the host — this JSON line is required - console.log(JSON.stringify({ ready: true, port })); -}); diff --git a/server/utils/plugin-loader.js b/server/utils/plugin-loader.js index 5759b715..bbcdfd28 100644 --- a/server/utils/plugin-loader.js +++ b/server/utils/plugin-loader.js @@ -7,7 +7,7 @@ const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins'); const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json'); const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry']; -const ALLOWED_TYPES = ['iframe', 'react', 'module']; +const ALLOWED_TYPES = ['react', 'module']; const ALLOWED_SLOTS = ['tab']; export function getPluginsDir() { @@ -96,7 +96,7 @@ export function scanPlugins() { description: manifest.description || '', author: manifest.author || '', icon: manifest.icon || 'Puzzle', - type: manifest.type || 'iframe', + type: manifest.type || 'module', slot: manifest.slot || 'tab', entry: manifest.entry, server: manifest.server || null, @@ -157,7 +157,24 @@ export function installPluginFromGit(url) { return reject(new Error(`Plugin directory "${repoName}" already exists`)); } - const gitProcess = spawn('git', ['clone', '--depth', '1', url, targetDir], { + // Clone into a temp directory so scanPlugins() never sees a partially-installed plugin + const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`)); + + const cleanupTemp = () => { + try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {} + }; + + const finalize = (manifest) => { + try { + fs.renameSync(tempDir, targetDir); + } catch (err) { + cleanupTemp(); + return reject(new Error(`Failed to move plugin into place: ${err.message}`)); + } + resolve(manifest); + }; + + const gitProcess = spawn('git', ['clone', '--depth', '1', url, tempDir], { stdio: ['ignore', 'pipe', 'pipe'], }); @@ -166,15 +183,14 @@ export function installPluginFromGit(url) { gitProcess.on('close', (code) => { if (code !== 0) { - // Clean up failed clone - try { fs.rmSync(targetDir, { recursive: true, force: true }); } catch {} + cleanupTemp(); return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`)); } // Validate manifest exists - const manifestPath = path.join(targetDir, 'manifest.json'); + const manifestPath = path.join(tempDir, 'manifest.json'); if (!fs.existsSync(manifestPath)) { - fs.rmSync(targetDir, { recursive: true, force: true }); + cleanupTemp(); return reject(new Error('Cloned repository does not contain a manifest.json')); } @@ -182,42 +198,44 @@ export function installPluginFromGit(url) { try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); } catch { - fs.rmSync(targetDir, { recursive: true, force: true }); + cleanupTemp(); return reject(new Error('manifest.json is not valid JSON')); } const validation = validateManifest(manifest); if (!validation.valid) { - fs.rmSync(targetDir, { recursive: true, force: true }); + cleanupTemp(); return reject(new Error(`Invalid manifest: ${validation.error}`)); } // Run npm install if package.json exists. // --ignore-scripts prevents postinstall hooks from executing arbitrary code. - const packageJsonPath = path.join(targetDir, 'package.json'); + const packageJsonPath = path.join(tempDir, 'package.json'); if (fs.existsSync(packageJsonPath)) { const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], { - cwd: targetDir, + cwd: tempDir, stdio: ['ignore', 'pipe', 'pipe'], }); npmProcess.on('close', (npmCode) => { if (npmCode !== 0) { - console.warn(`[Plugins] npm install for ${repoName} exited with code ${npmCode}`); + cleanupTemp(); + return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`)); } - resolve(manifest); + finalize(manifest); }); - npmProcess.on('error', () => { - // npm not available, continue anyway - resolve(manifest); + npmProcess.on('error', (err) => { + cleanupTemp(); + reject(err); }); } else { - resolve(manifest); + finalize(manifest); } }); gitProcess.on('error', (err) => { + cleanupTemp(); reject(new Error(`Failed to spawn git: ${err.message}`)); }); }); @@ -265,8 +283,13 @@ export function updatePluginFromGit(name) { cwd: pluginDir, stdio: ['ignore', 'pipe', 'pipe'], }); - npmProcess.on('close', () => resolve(manifest)); - npmProcess.on('error', () => resolve(manifest)); + npmProcess.on('close', (npmCode) => { + if (npmCode !== 0) { + return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`)); + } + resolve(manifest); + }); + npmProcess.on('error', (err) => reject(err)); } else { resolve(manifest); } diff --git a/src/components/plugins/PluginSettingsTab.tsx b/src/components/plugins/PluginSettingsTab.tsx index c1d6578f..a285ed7c 100644 --- a/src/components/plugins/PluginSettingsTab.tsx +++ b/src/components/plugins/PluginSettingsTab.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ChevronRight } from 'lucide-react'; +import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ChevronRight, ShieldAlert, ExternalLink, BookOpen } from 'lucide-react'; import { usePlugins } from '../../contexts/PluginsContext'; import PluginIcon from './PluginIcon'; import type { Plugin } from '../../contexts/PluginsContext'; @@ -314,6 +314,43 @@ export default function PluginSettingsTab() { {installError && (

{installError}

)} + +

+ + + Plugins run with full access to the host page. Only install plugins + whose source code you have reviewed or from authors you trust. + +

+
+ + {/* Build your own */} +
+ +
+

+ Build your own plugin or start from an example. +

+
+
+ + Starter plugin + + | + + Docs + +
{/* Plugin List */} diff --git a/src/contexts/PluginsContext.tsx b/src/contexts/PluginsContext.tsx index 4da04fd9..987a7f61 100644 --- a/src/contexts/PluginsContext.tsx +++ b/src/contexts/PluginsContext.tsx @@ -9,7 +9,7 @@ export type Plugin = { description: string; author: string; icon: string; - type: 'iframe' | 'react' | 'module'; + type: 'react' | 'module'; slot: 'tab'; entry: string; server: string | null; @@ -22,11 +22,12 @@ export type Plugin = { type PluginsContextValue = { plugins: Plugin[]; loading: boolean; + pluginsError: string | null; refreshPlugins: () => Promise; installPlugin: (url: string) => Promise<{ success: boolean; error?: string }>; uninstallPlugin: (name: string) => Promise<{ success: boolean; error?: string }>; updatePlugin: (name: string) => Promise<{ success: boolean; error?: string }>; - togglePlugin: (name: string, enabled: boolean) => Promise; + togglePlugin: (name: string, enabled: boolean) => Promise<{ success: boolean; error: string | null }>; }; const PluginsContext = createContext(null); @@ -42,6 +43,7 @@ export function usePlugins() { export function PluginsProvider({ children }: { children: ReactNode }) { const [plugins, setPlugins] = useState([]); const [loading, setLoading] = useState(true); + const [pluginsError, setPluginsError] = useState(null); const refreshPlugins = useCallback(async () => { try { @@ -49,8 +51,20 @@ export function PluginsProvider({ children }: { children: ReactNode }) { if (res.ok) { const data = await res.json(); setPlugins(data.plugins || []); + setPluginsError(null); + } else { + let errorMessage = `Failed to fetch plugins (${res.status})`; + try { + const data = await res.json(); + errorMessage = data.details || data.error || errorMessage; + } catch { + errorMessage = res.statusText || errorMessage; + } + setPluginsError(errorMessage); } } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch plugins'; + setPluginsError(message); console.error('[Plugins] Failed to fetch plugins:', err); } finally { setLoading(false); @@ -110,20 +124,32 @@ export function PluginsProvider({ children }: { children: ReactNode }) { } }, [refreshPlugins]); - const togglePlugin = useCallback(async (name: string, enabled: boolean) => { + const togglePlugin = useCallback(async (name: string, enabled: boolean): Promise<{ success: boolean; error: string | null }> => { try { - await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/enable`, { + const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/enable`, { method: 'PUT', body: JSON.stringify({ enabled }), }); + if (!res.ok) { + let errorMessage = `Toggle failed (${res.status})`; + try { + const data = await res.json(); + errorMessage = data.details || data.error || errorMessage; + } catch { + // response body wasn't JSON, use status text + errorMessage = res.statusText || errorMessage; + } + return { success: false, error: errorMessage }; + } await refreshPlugins(); + return { success: true, error: null }; } catch (err) { - console.error('[Plugins] Failed to toggle plugin:', err); + return { success: false, error: err instanceof Error ? err.message : 'Toggle failed' }; } }, [refreshPlugins]); return ( - + {children} );