plugin-loader.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. import fs from 'fs';
  2. import path from 'path';
  3. import os from 'os';
  4. import { spawn } from 'child_process';
  5. const PLUGINS_DIR = path.join(os.homedir(), '.pilotdeck', 'plugins');
  6. const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.pilotdeck', 'plugins.json');
  7. const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
  8. /** Strip embedded credentials from a repo URL before exposing it to the client. */
  9. function sanitizeRepoUrl(raw) {
  10. try {
  11. const u = new URL(raw);
  12. u.username = '';
  13. u.password = '';
  14. return u.toString().replace(/\/$/, '');
  15. } catch {
  16. // Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
  17. return raw.replace(/\/\/[^@/]+@/, '//');
  18. }
  19. }
  20. const ALLOWED_TYPES = ['react', 'module'];
  21. const ALLOWED_SLOTS = ['tab'];
  22. export function getPluginsDir() {
  23. if (!fs.existsSync(PLUGINS_DIR)) {
  24. fs.mkdirSync(PLUGINS_DIR, { recursive: true });
  25. }
  26. return PLUGINS_DIR;
  27. }
  28. export function getPluginsConfig() {
  29. try {
  30. if (fs.existsSync(PLUGINS_CONFIG_PATH)) {
  31. return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));
  32. }
  33. } catch {
  34. // Corrupted config, start fresh
  35. }
  36. return {};
  37. }
  38. export function savePluginsConfig(config) {
  39. const dir = path.dirname(PLUGINS_CONFIG_PATH);
  40. if (!fs.existsSync(dir)) {
  41. fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
  42. }
  43. fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
  44. }
  45. export function validateManifest(manifest) {
  46. if (!manifest || typeof manifest !== 'object') {
  47. return { valid: false, error: 'Manifest must be a JSON object' };
  48. }
  49. for (const field of REQUIRED_MANIFEST_FIELDS) {
  50. if (!manifest[field] || typeof manifest[field] !== 'string') {
  51. return { valid: false, error: `Missing or invalid required field: ${field}` };
  52. }
  53. }
  54. // Sanitize name — only allow alphanumeric, hyphens, underscores
  55. if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
  56. return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };
  57. }
  58. if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {
  59. return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };
  60. }
  61. if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {
  62. return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
  63. }
  64. // Validate entry is a relative path without traversal
  65. if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
  66. return { valid: false, error: 'Entry must be a relative path without ".."' };
  67. }
  68. if (manifest.server !== undefined && manifest.server !== null) {
  69. if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
  70. return { valid: false, error: 'Server entry must be a relative path string without ".."' };
  71. }
  72. }
  73. if (manifest.permissions !== undefined) {
  74. if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
  75. return { valid: false, error: 'Permissions must be an array of strings' };
  76. }
  77. }
  78. return { valid: true };
  79. }
  80. const BUILD_TIMEOUT_MS = 60_000;
  81. /** Run `npm run build` if the plugin's package.json declares a build script. */
  82. function runBuildIfNeeded(dir, packageJsonPath, onSuccess, onError) {
  83. try {
  84. const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
  85. if (!pkg.scripts?.build) {
  86. return onSuccess();
  87. }
  88. } catch {
  89. return onSuccess(); // Unreadable package.json — skip build
  90. }
  91. const buildProcess = spawn('npm', ['run', 'build'], {
  92. cwd: dir,
  93. stdio: ['ignore', 'pipe', 'pipe'],
  94. });
  95. let stderr = '';
  96. let settled = false;
  97. const timer = setTimeout(() => {
  98. if (settled) return;
  99. settled = true;
  100. buildProcess.removeAllListeners();
  101. buildProcess.kill();
  102. onError(new Error('npm run build timed out'));
  103. }, BUILD_TIMEOUT_MS);
  104. buildProcess.stderr.on('data', (data) => { stderr += data.toString(); });
  105. buildProcess.on('close', (code) => {
  106. if (settled) return;
  107. settled = true;
  108. clearTimeout(timer);
  109. if (code !== 0) {
  110. return onError(new Error(`npm run build failed (exit code ${code}): ${stderr.trim()}`));
  111. }
  112. onSuccess();
  113. });
  114. buildProcess.on('error', (err) => {
  115. if (settled) return;
  116. settled = true;
  117. clearTimeout(timer);
  118. onError(new Error(`Failed to spawn build: ${err.message}`));
  119. });
  120. }
  121. export function scanPlugins() {
  122. const pluginsDir = getPluginsDir();
  123. const config = getPluginsConfig();
  124. const plugins = [];
  125. let entries;
  126. try {
  127. entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
  128. } catch {
  129. return plugins;
  130. }
  131. const seenNames = new Set();
  132. for (const entry of entries) {
  133. if (!entry.isDirectory()) continue;
  134. // Skip transient temp directories from in-progress installs
  135. if (entry.name.startsWith('.tmp-')) continue;
  136. const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
  137. if (!fs.existsSync(manifestPath)) continue;
  138. try {
  139. const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
  140. const validation = validateManifest(manifest);
  141. if (!validation.valid) {
  142. console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);
  143. continue;
  144. }
  145. // Skip duplicate manifest names
  146. if (seenNames.has(manifest.name)) {
  147. console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
  148. continue;
  149. }
  150. seenNames.add(manifest.name);
  151. // Try to read git remote URL
  152. let repoUrl = null;
  153. try {
  154. const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');
  155. if (fs.existsSync(gitConfigPath)) {
  156. const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');
  157. const match = gitConfig.match(/url\s*=\s*(.+)/);
  158. if (match) {
  159. repoUrl = match[1].trim().replace(/\.git$/, '');
  160. // Convert SSH URLs to HTTPS
  161. if (repoUrl.startsWith('git@')) {
  162. repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
  163. }
  164. // Strip embedded credentials (e.g. https://user:pass@host/...)
  165. repoUrl = sanitizeRepoUrl(repoUrl);
  166. }
  167. }
  168. } catch { /* ignore */ }
  169. plugins.push({
  170. name: manifest.name,
  171. displayName: manifest.displayName,
  172. version: manifest.version || '0.0.0',
  173. description: manifest.description || '',
  174. author: manifest.author || '',
  175. icon: manifest.icon || 'Puzzle',
  176. type: manifest.type || 'module',
  177. slot: manifest.slot || 'tab',
  178. entry: manifest.entry,
  179. server: manifest.server || null,
  180. permissions: manifest.permissions || [],
  181. enabled: config[manifest.name]?.enabled !== false, // enabled by default
  182. dirName: entry.name,
  183. repoUrl,
  184. });
  185. } catch (err) {
  186. console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);
  187. }
  188. }
  189. return plugins;
  190. }
  191. export function getPluginDir(name) {
  192. const plugins = scanPlugins();
  193. const plugin = plugins.find(p => p.name === name);
  194. if (!plugin) return null;
  195. return path.join(getPluginsDir(), plugin.dirName);
  196. }
  197. export function resolvePluginAssetPath(name, assetPath) {
  198. const pluginDir = getPluginDir(name);
  199. if (!pluginDir) return null;
  200. const resolved = path.resolve(pluginDir, assetPath);
  201. // Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
  202. if (!fs.existsSync(resolved)) return null;
  203. const realResolved = fs.realpathSync(resolved);
  204. const realPluginDir = fs.realpathSync(pluginDir);
  205. if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
  206. return null;
  207. }
  208. return realResolved;
  209. }
  210. export function installPluginFromGit(url) {
  211. return new Promise((resolve, reject) => {
  212. if (typeof url !== 'string' || !url.trim()) {
  213. return reject(new Error('Invalid URL: must be a non-empty string'));
  214. }
  215. if (url.startsWith('-')) {
  216. return reject(new Error('Invalid URL: must not start with "-"'));
  217. }
  218. // Extract repo name from URL for directory name
  219. const urlClean = url.replace(/\.git$/, '').replace(/\/$/, '');
  220. const repoName = urlClean.split('/').pop();
  221. if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {
  222. return reject(new Error('Could not determine a valid directory name from the URL'));
  223. }
  224. const pluginsDir = getPluginsDir();
  225. const targetDir = path.resolve(pluginsDir, repoName);
  226. // Ensure the resolved target directory stays within the plugins directory
  227. if (!targetDir.startsWith(pluginsDir + path.sep)) {
  228. return reject(new Error('Invalid plugin directory path'));
  229. }
  230. if (fs.existsSync(targetDir)) {
  231. return reject(new Error(`Plugin directory "${repoName}" already exists`));
  232. }
  233. // Clone into a temp directory so scanPlugins() never sees a partially-installed plugin
  234. const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));
  235. const cleanupTemp = () => {
  236. try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
  237. };
  238. const finalize = (manifest) => {
  239. try {
  240. fs.renameSync(tempDir, targetDir);
  241. } catch (err) {
  242. cleanupTemp();
  243. return reject(new Error(`Failed to move plugin into place: ${err.message}`));
  244. }
  245. resolve(manifest);
  246. };
  247. const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], {
  248. stdio: ['ignore', 'pipe', 'pipe'],
  249. });
  250. let stderr = '';
  251. gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
  252. gitProcess.on('close', (code) => {
  253. if (code !== 0) {
  254. cleanupTemp();
  255. return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
  256. }
  257. // Validate manifest exists
  258. const manifestPath = path.join(tempDir, 'manifest.json');
  259. if (!fs.existsSync(manifestPath)) {
  260. cleanupTemp();
  261. return reject(new Error('Cloned repository does not contain a manifest.json'));
  262. }
  263. let manifest;
  264. try {
  265. manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
  266. } catch {
  267. cleanupTemp();
  268. return reject(new Error('manifest.json is not valid JSON'));
  269. }
  270. const validation = validateManifest(manifest);
  271. if (!validation.valid) {
  272. cleanupTemp();
  273. return reject(new Error(`Invalid manifest: ${validation.error}`));
  274. }
  275. // Reject if another installed plugin already uses this name
  276. const existing = scanPlugins().find(p => p.name === manifest.name);
  277. if (existing) {
  278. cleanupTemp();
  279. return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
  280. }
  281. // Run npm install if package.json exists.
  282. // --ignore-scripts prevents postinstall hooks from executing arbitrary code.
  283. const packageJsonPath = path.join(tempDir, 'package.json');
  284. if (fs.existsSync(packageJsonPath)) {
  285. const npmProcess = spawn('npm', ['install', '--ignore-scripts'], {
  286. cwd: tempDir,
  287. stdio: ['ignore', 'pipe', 'pipe'],
  288. });
  289. npmProcess.on('close', (npmCode) => {
  290. if (npmCode !== 0) {
  291. cleanupTemp();
  292. return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
  293. }
  294. runBuildIfNeeded(tempDir, packageJsonPath, () => finalize(manifest), (err) => { cleanupTemp(); reject(err); });
  295. });
  296. npmProcess.on('error', (err) => {
  297. cleanupTemp();
  298. reject(err);
  299. });
  300. } else {
  301. finalize(manifest);
  302. }
  303. });
  304. gitProcess.on('error', (err) => {
  305. cleanupTemp();
  306. reject(new Error(`Failed to spawn git: ${err.message}`));
  307. });
  308. });
  309. }
  310. export function updatePluginFromGit(name) {
  311. return new Promise((resolve, reject) => {
  312. const pluginDir = getPluginDir(name);
  313. if (!pluginDir) {
  314. return reject(new Error(`Plugin "${name}" not found`));
  315. }
  316. // Only fast-forward to avoid silent divergence
  317. const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {
  318. cwd: pluginDir,
  319. stdio: ['ignore', 'pipe', 'pipe'],
  320. });
  321. let stderr = '';
  322. gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
  323. gitProcess.on('close', (code) => {
  324. if (code !== 0) {
  325. return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));
  326. }
  327. // Re-validate manifest after update
  328. const manifestPath = path.join(pluginDir, 'manifest.json');
  329. let manifest;
  330. try {
  331. manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
  332. } catch {
  333. return reject(new Error('manifest.json is not valid JSON after update'));
  334. }
  335. const validation = validateManifest(manifest);
  336. if (!validation.valid) {
  337. return reject(new Error(`Invalid manifest after update: ${validation.error}`));
  338. }
  339. // Re-run npm install if package.json exists
  340. const packageJsonPath = path.join(pluginDir, 'package.json');
  341. if (fs.existsSync(packageJsonPath)) {
  342. const npmProcess = spawn('npm', ['install', '--ignore-scripts'], {
  343. cwd: pluginDir,
  344. stdio: ['ignore', 'pipe', 'pipe'],
  345. });
  346. npmProcess.on('close', (npmCode) => {
  347. if (npmCode !== 0) {
  348. return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));
  349. }
  350. runBuildIfNeeded(pluginDir, packageJsonPath, () => resolve(manifest), (err) => reject(err));
  351. });
  352. npmProcess.on('error', (err) => reject(err));
  353. } else {
  354. resolve(manifest);
  355. }
  356. });
  357. gitProcess.on('error', (err) => {
  358. reject(new Error(`Failed to spawn git: ${err.message}`));
  359. });
  360. });
  361. }
  362. export async function uninstallPlugin(name) {
  363. const pluginDir = getPluginDir(name);
  364. if (!pluginDir) {
  365. throw new Error(`Plugin "${name}" not found`);
  366. }
  367. // On Windows, file handles may be released slightly after process exit.
  368. // Retry a few times with a short delay before giving up.
  369. const MAX_RETRIES = 5;
  370. const RETRY_DELAY_MS = 500;
  371. for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
  372. try {
  373. fs.rmSync(pluginDir, { recursive: true, force: true });
  374. break;
  375. } catch (err) {
  376. if (err.code === 'EBUSY' && attempt < MAX_RETRIES) {
  377. await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
  378. } else {
  379. throw err;
  380. }
  381. }
  382. }
  383. // Remove from config
  384. const config = getPluginsConfig();
  385. delete config[name];
  386. savePluginsConfig(config);
  387. }