mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 08:27:40 +00:00
Replace single updatingPlugin string state with a Set to allow multiple plugins to update simultaneously. Also disable the update button and show a descriptive tooltip when a plugin has no git remote configured.
383 lines
12 KiB
JavaScript
383 lines
12 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { spawn } from 'child_process';
|
|
|
|
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 = ['react', 'module'];
|
|
const ALLOWED_SLOTS = ['tab'];
|
|
|
|
export function getPluginsDir() {
|
|
if (!fs.existsSync(PLUGINS_DIR)) {
|
|
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
}
|
|
return PLUGINS_DIR;
|
|
}
|
|
|
|
export function getPluginsConfig() {
|
|
try {
|
|
if (fs.existsSync(PLUGINS_CONFIG_PATH)) {
|
|
return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));
|
|
}
|
|
} catch {
|
|
// Corrupted config, start fresh
|
|
}
|
|
return {};
|
|
}
|
|
|
|
export function savePluginsConfig(config) {
|
|
const dir = path.dirname(PLUGINS_CONFIG_PATH);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
}
|
|
fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
}
|
|
|
|
export function validateManifest(manifest) {
|
|
if (!manifest || typeof manifest !== 'object') {
|
|
return { valid: false, error: 'Manifest must be a JSON object' };
|
|
}
|
|
|
|
for (const field of REQUIRED_MANIFEST_FIELDS) {
|
|
if (!manifest[field] || typeof manifest[field] !== 'string') {
|
|
return { valid: false, error: `Missing or invalid required field: ${field}` };
|
|
}
|
|
}
|
|
|
|
// Sanitize name — only allow alphanumeric, hyphens, underscores
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
|
|
return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };
|
|
}
|
|
|
|
if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {
|
|
return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };
|
|
}
|
|
|
|
if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {
|
|
return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
|
|
}
|
|
|
|
// Validate entry is a relative path without traversal
|
|
if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
|
|
return { valid: false, error: 'Entry must be a relative path without ".."' };
|
|
}
|
|
|
|
if (manifest.server !== undefined && manifest.server !== null) {
|
|
if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
|
|
return { valid: false, error: 'Server entry must be a relative path string without ".."' };
|
|
}
|
|
}
|
|
|
|
if (manifest.permissions !== undefined) {
|
|
if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
|
|
return { valid: false, error: 'Permissions must be an array of strings' };
|
|
}
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
export function scanPlugins() {
|
|
const pluginsDir = getPluginsDir();
|
|
const config = getPluginsConfig();
|
|
const plugins = [];
|
|
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
|
|
} catch {
|
|
return plugins;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
|
|
const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
|
|
if (!fs.existsSync(manifestPath)) continue;
|
|
|
|
try {
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
const validation = validateManifest(manifest);
|
|
if (!validation.valid) {
|
|
console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);
|
|
continue;
|
|
}
|
|
|
|
// Try to read git remote URL
|
|
let repoUrl = null;
|
|
try {
|
|
const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');
|
|
if (fs.existsSync(gitConfigPath)) {
|
|
const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');
|
|
const match = gitConfig.match(/url\s*=\s*(.+)/);
|
|
if (match) {
|
|
repoUrl = match[1].trim().replace(/\.git$/, '');
|
|
// Convert SSH URLs to HTTPS
|
|
if (repoUrl.startsWith('git@')) {
|
|
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
|
|
}
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
plugins.push({
|
|
name: manifest.name,
|
|
displayName: manifest.displayName,
|
|
version: manifest.version || '0.0.0',
|
|
description: manifest.description || '',
|
|
author: manifest.author || '',
|
|
icon: manifest.icon || 'Puzzle',
|
|
type: manifest.type || 'module',
|
|
slot: manifest.slot || 'tab',
|
|
entry: manifest.entry,
|
|
server: manifest.server || null,
|
|
permissions: manifest.permissions || [],
|
|
enabled: config[manifest.name]?.enabled !== false, // enabled by default
|
|
dirName: entry.name,
|
|
repoUrl,
|
|
});
|
|
} catch (err) {
|
|
console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);
|
|
}
|
|
}
|
|
|
|
return plugins;
|
|
}
|
|
|
|
export function getPluginDir(name) {
|
|
const plugins = scanPlugins();
|
|
const plugin = plugins.find(p => p.name === name);
|
|
if (!plugin) return null;
|
|
return path.join(getPluginsDir(), plugin.dirName);
|
|
}
|
|
|
|
export function resolvePluginAssetPath(name, assetPath) {
|
|
const pluginDir = getPluginDir(name);
|
|
if (!pluginDir) return null;
|
|
|
|
const resolved = path.resolve(pluginDir, assetPath);
|
|
|
|
// Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
|
|
if (!fs.existsSync(resolved)) return null;
|
|
|
|
const realResolved = fs.realpathSync(resolved);
|
|
const realPluginDir = fs.realpathSync(pluginDir);
|
|
if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
|
|
return null;
|
|
}
|
|
|
|
return realResolved;
|
|
}
|
|
|
|
export function installPluginFromGit(url) {
|
|
return new Promise((resolve, reject) => {
|
|
if (typeof url !== 'string' || !url.trim()) {
|
|
return reject(new Error('Invalid URL: must be a non-empty string'));
|
|
}
|
|
if (url.startsWith('-')) {
|
|
return reject(new Error('Invalid URL: must not start with "-"'));
|
|
}
|
|
|
|
// Extract repo name from URL for directory name
|
|
const urlClean = url.replace(/\.git$/, '').replace(/\/$/, '');
|
|
const repoName = urlClean.split('/').pop();
|
|
|
|
if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {
|
|
return reject(new Error('Could not determine a valid directory name from the URL'));
|
|
}
|
|
|
|
const pluginsDir = getPluginsDir();
|
|
const targetDir = path.resolve(pluginsDir, repoName);
|
|
|
|
// Ensure the resolved target directory stays within the plugins directory
|
|
if (!targetDir.startsWith(pluginsDir + path.sep)) {
|
|
return reject(new Error('Invalid plugin directory path'));
|
|
}
|
|
|
|
if (fs.existsSync(targetDir)) {
|
|
return reject(new Error(`Plugin directory "${repoName}" already exists`));
|
|
}
|
|
|
|
// 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'],
|
|
});
|
|
|
|
let stderr = '';
|
|
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
|
|
gitProcess.on('close', (code) => {
|
|
if (code !== 0) {
|
|
cleanupTemp();
|
|
return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
|
|
}
|
|
|
|
// Validate manifest exists
|
|
const manifestPath = path.join(tempDir, 'manifest.json');
|
|
if (!fs.existsSync(manifestPath)) {
|
|
cleanupTemp();
|
|
return reject(new Error('Cloned repository does not contain a manifest.json'));
|
|
}
|
|
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
} catch {
|
|
cleanupTemp();
|
|
return reject(new Error('manifest.json is not valid JSON'));
|
|
}
|
|
|
|
const validation = validateManifest(manifest);
|
|
if (!validation.valid) {
|
|
cleanupTemp();
|
|
return reject(new Error(`Invalid manifest: ${validation.error}`));
|
|
}
|
|
|
|
// Reject if another installed plugin already uses this name
|
|
const existing = scanPlugins().find(p => p.name === manifest.name);
|
|
if (existing) {
|
|
cleanupTemp();
|
|
return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
|
|
}
|
|
|
|
// Run npm install if package.json exists.
|
|
// --ignore-scripts prevents postinstall hooks from executing arbitrary code.
|
|
const packageJsonPath = path.join(tempDir, 'package.json');
|
|
if (fs.existsSync(packageJsonPath)) {
|
|
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
|
|
cwd: tempDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
npmProcess.on('close', (npmCode) => {
|
|
if (npmCode !== 0) {
|
|
cleanupTemp();
|
|
return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
|
|
}
|
|
finalize(manifest);
|
|
});
|
|
|
|
npmProcess.on('error', (err) => {
|
|
cleanupTemp();
|
|
reject(err);
|
|
});
|
|
} else {
|
|
finalize(manifest);
|
|
}
|
|
});
|
|
|
|
gitProcess.on('error', (err) => {
|
|
cleanupTemp();
|
|
reject(new Error(`Failed to spawn git: ${err.message}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
export function updatePluginFromGit(name) {
|
|
return new Promise((resolve, reject) => {
|
|
const pluginDir = getPluginDir(name);
|
|
if (!pluginDir) {
|
|
return reject(new Error(`Plugin "${name}" not found`));
|
|
}
|
|
|
|
// Only fast-forward to avoid silent divergence
|
|
const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {
|
|
cwd: pluginDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
let stderr = '';
|
|
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
|
|
gitProcess.on('close', (code) => {
|
|
if (code !== 0) {
|
|
return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));
|
|
}
|
|
|
|
// Re-validate manifest after update
|
|
const manifestPath = path.join(pluginDir, 'manifest.json');
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
} catch {
|
|
return reject(new Error('manifest.json is not valid JSON after update'));
|
|
}
|
|
|
|
const validation = validateManifest(manifest);
|
|
if (!validation.valid) {
|
|
return reject(new Error(`Invalid manifest after update: ${validation.error}`));
|
|
}
|
|
|
|
// Re-run npm install if package.json exists
|
|
const packageJsonPath = path.join(pluginDir, 'package.json');
|
|
if (fs.existsSync(packageJsonPath)) {
|
|
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
|
|
cwd: pluginDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
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);
|
|
}
|
|
});
|
|
|
|
gitProcess.on('error', (err) => {
|
|
reject(new Error(`Failed to spawn git: ${err.message}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function uninstallPlugin(name) {
|
|
const pluginDir = getPluginDir(name);
|
|
if (!pluginDir) {
|
|
throw new Error(`Plugin "${name}" not found`);
|
|
}
|
|
|
|
// On Windows, file handles may be released slightly after process exit.
|
|
// Retry a few times with a short delay before giving up.
|
|
const MAX_RETRIES = 5;
|
|
const RETRY_DELAY_MS = 500;
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
try {
|
|
fs.rmSync(pluginDir, { recursive: true, force: true });
|
|
break;
|
|
} catch (err) {
|
|
if (err.code === 'EBUSY' && attempt < MAX_RETRIES) {
|
|
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove from config
|
|
const config = getPluginsConfig();
|
|
delete config[name];
|
|
savePluginsConfig(config);
|
|
}
|