| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457 |
- import fs from 'fs';
- import path from 'path';
- import os from 'os';
- import { spawn } from 'child_process';
- const PLUGINS_DIR = path.join(os.homedir(), '.pilotdeck', 'plugins');
- const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.pilotdeck', 'plugins.json');
- const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
- /** Strip embedded credentials from a repo URL before exposing it to the client. */
- function sanitizeRepoUrl(raw) {
- try {
- const u = new URL(raw);
- u.username = '';
- u.password = '';
- return u.toString().replace(/\/$/, '');
- } catch {
- // Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
- return raw.replace(/\/\/[^@/]+@/, '//');
- }
- }
- 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 };
- }
- const BUILD_TIMEOUT_MS = 60_000;
- /** Run `npm run build` if the plugin's package.json declares a build script. */
- function runBuildIfNeeded(dir, packageJsonPath, onSuccess, onError) {
- try {
- const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
- if (!pkg.scripts?.build) {
- return onSuccess();
- }
- } catch {
- return onSuccess(); // Unreadable package.json — skip build
- }
- const buildProcess = spawn('npm', ['run', 'build'], {
- cwd: dir,
- stdio: ['ignore', 'pipe', 'pipe'],
- });
- let stderr = '';
- let settled = false;
- const timer = setTimeout(() => {
- if (settled) return;
- settled = true;
- buildProcess.removeAllListeners();
- buildProcess.kill();
- onError(new Error('npm run build timed out'));
- }, BUILD_TIMEOUT_MS);
- buildProcess.stderr.on('data', (data) => { stderr += data.toString(); });
- buildProcess.on('close', (code) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- if (code !== 0) {
- return onError(new Error(`npm run build failed (exit code ${code}): ${stderr.trim()}`));
- }
- onSuccess();
- });
- buildProcess.on('error', (err) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- onError(new Error(`Failed to spawn build: ${err.message}`));
- });
- }
- export function scanPlugins() {
- const pluginsDir = getPluginsDir();
- const config = getPluginsConfig();
- const plugins = [];
- let entries;
- try {
- entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
- } catch {
- return plugins;
- }
- const seenNames = new Set();
- for (const entry of entries) {
- if (!entry.isDirectory()) continue;
- // Skip transient temp directories from in-progress installs
- if (entry.name.startsWith('.tmp-')) 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;
- }
- // Skip duplicate manifest names
- if (seenNames.has(manifest.name)) {
- console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
- continue;
- }
- seenNames.add(manifest.name);
- // 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/');
- }
- // Strip embedded credentials (e.g. https://user:pass@host/...)
- repoUrl = sanitizeRepoUrl(repoUrl);
- }
- }
- } 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', '--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})`));
- }
- runBuildIfNeeded(tempDir, packageJsonPath, () => finalize(manifest), (err) => { cleanupTemp(); reject(err); });
- });
- 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', '--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})`));
- }
- runBuildIfNeeded(pluginDir, packageJsonPath, () => resolve(manifest), (err) => reject(err));
- });
- 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);
- }
|