From f3587f9b281f4d528d229f8e87024379f61cf93e Mon Sep 17 00:00:00 2001 From: David Jones Date: Sun, 13 Jul 2025 08:11:24 -0400 Subject: [PATCH 1/2] Plugin local installer --- .../components/PluginInstallerPage.tsx | 25 +- .../components/common/FileUploadZone.tsx | 286 +++++++++++++++ .../install-methods/GitHubInstallForm.tsx | 331 ++++++++++++++++++ .../install-methods/InstallMethodTabs.tsx | 218 ++++++++++++ .../install-methods/LocalFileInstallForm.tsx | 160 +++++++++ .../hooks/usePluginInstaller.ts | 238 ++++++++----- .../services/pluginInstallerService.ts | 169 ++++++++- .../src/features/plugin-installer/types.ts | 71 +++- .../plugin-installer/utils/fileValidation.ts | 161 +++++++++ 9 files changed, 1544 insertions(+), 115 deletions(-) create mode 100644 frontend/src/features/plugin-installer/components/common/FileUploadZone.tsx create mode 100644 frontend/src/features/plugin-installer/components/install-methods/GitHubInstallForm.tsx create mode 100644 frontend/src/features/plugin-installer/components/install-methods/InstallMethodTabs.tsx create mode 100644 frontend/src/features/plugin-installer/components/install-methods/LocalFileInstallForm.tsx create mode 100644 frontend/src/features/plugin-installer/utils/fileValidation.ts diff --git a/frontend/src/features/plugin-installer/components/PluginInstallerPage.tsx b/frontend/src/features/plugin-installer/components/PluginInstallerPage.tsx index 517287c..d8151f8 100644 --- a/frontend/src/features/plugin-installer/components/PluginInstallerPage.tsx +++ b/frontend/src/features/plugin-installer/components/PluginInstallerPage.tsx @@ -14,10 +14,11 @@ import { Extension as ExtensionIcon, Add as AddIcon } from '@mui/icons-material'; -import PluginInstallForm from './PluginInstallForm'; +import InstallMethodTabs from './install-methods/InstallMethodTabs'; import InstallationProgress from './InstallationProgress'; import InstallationResult from './InstallationResult'; import { usePluginInstaller } from '../hooks'; +import { PluginInstallRequest } from '../types'; const PluginInstallerPage: React.FC = () => { const navigate = useNavigate(); @@ -34,14 +35,14 @@ const PluginInstallerPage: React.FC = () => { }, [navigate]); const handleInstallAnother = useCallback(() => { - resetInstallation(); + resetInstallation('github'); }, [resetInstallation]); const handleGoToPluginManager = useCallback(() => { navigate('/plugin-manager'); }, [navigate]); - const handleInstall = useCallback(async (request: any) => { + const handleInstall = useCallback(async (request: PluginInstallRequest) => { await installPlugin(request); }, [installPlugin]); @@ -83,7 +84,7 @@ const PluginInstallerPage: React.FC = () => { - Install plugins from GitHub repositories. Plugins will be downloaded, validated, and installed for your account only. + Install plugins from GitHub repositories or upload local archive files. Plugins will be downloaded, validated, and installed for your account only. @@ -93,8 +94,9 @@ const PluginInstallerPage: React.FC = () => { How Plugin Installation Works: -
  • Enter a GitHub repository URL containing a BrainDrive plugin
  • -
  • The plugin will be downloaded from the latest release or specified version
  • +
  • Choose your installation method: GitHub repository or local file upload
  • +
  • For GitHub: Enter repository URL and select version
  • +
  • For local files: Upload ZIP, RAR, or TAR.GZ archive containing the plugin
  • Plugin files are validated and installed securely for your account
  • Only you will have access to the installed plugin
  • You can uninstall or update the plugin at any time
  • @@ -103,7 +105,7 @@ const PluginInstallerPage: React.FC = () => { {/* Installation Form */} {showForm && ( - { variant="outlined" size="small" startIcon={} - onClick={resetInstallation} + onClick={() => resetInstallation('github')} > Try Again @@ -180,9 +182,10 @@ const PluginInstallerPage: React.FC = () => { If you're having trouble installing a plugin, make sure: -
  • The repository URL is correct and accessible
  • -
  • The repository contains a valid BrainDrive plugin
  • -
  • The plugin has releases with prebuilt files
  • +
  • For GitHub: The repository URL is correct and accessible
  • +
  • For GitHub: The repository contains a valid BrainDrive plugin with releases
  • +
  • For local files: The archive contains a valid plugin structure with plugin.json
  • +
  • For local files: The file format is supported (ZIP, RAR, TAR.GZ)
  • You have a stable internet connection
  • diff --git a/frontend/src/features/plugin-installer/components/common/FileUploadZone.tsx b/frontend/src/features/plugin-installer/components/common/FileUploadZone.tsx new file mode 100644 index 0000000..6ba7d89 --- /dev/null +++ b/frontend/src/features/plugin-installer/components/common/FileUploadZone.tsx @@ -0,0 +1,286 @@ +import React, { useCallback, useState, useRef } from 'react'; +import { + Box, + Typography, + Button, + LinearProgress, + Alert, + Chip, + IconButton, + Paper +} from '@mui/material'; +import { + CloudUpload as CloudUploadIcon, + InsertDriveFile as FileIcon, + Clear as ClearIcon, + CheckCircle as CheckCircleIcon, + Error as ErrorIcon +} from '@mui/icons-material'; +import { validateArchiveFile, formatFileSize } from '../../utils/fileValidation'; +import { FileUploadState, ArchiveValidationResult } from '../../types'; + +interface FileUploadZoneProps { + onFileSelect: (file: File) => void; + onFileRemove: () => void; + uploadState: FileUploadState; + disabled?: boolean; + accept?: string; + maxSize?: number; + className?: string; +} + +const FileUploadZone: React.FC = ({ + onFileSelect, + onFileRemove, + uploadState, + disabled = false, + accept = '.zip,.rar,.tar.gz,.tgz', + maxSize = 100 * 1024 * 1024, // 100MB + className +}) => { + const [isDragOver, setIsDragOver] = useState(false); + const [validation, setValidation] = useState(null); + const fileInputRef = useRef(null); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) { + setIsDragOver(true); + } + }, [disabled]); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + if (disabled) return; + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + handleFileSelection(files[0]); + } + }, [disabled]); + + const handleFileInputChange = useCallback((e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + handleFileSelection(files[0]); + } + }, []); + + const handleFileSelection = useCallback((file: File) => { + const validationResult = validateArchiveFile(file); + setValidation(validationResult); + + if (validationResult.isValid) { + onFileSelect(file); + } + }, [onFileSelect]); + + const handleBrowseClick = useCallback(() => { + if (!disabled && fileInputRef.current) { + fileInputRef.current.click(); + } + }, [disabled]); + + const handleRemoveFile = useCallback(() => { + setValidation(null); + onFileRemove(); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, [onFileRemove]); + + const getStatusIcon = () => { + if (uploadState.error || (validation && !validation.isValid)) { + return ; + } + if (uploadState.file && validation?.isValid) { + return ; + } + return ; + }; + + const getStatusColor = () => { + if (uploadState.error || (validation && !validation.isValid)) { + return 'error.main'; + } + if (uploadState.file && validation?.isValid) { + return 'success.main'; + } + if (isDragOver) { + return 'primary.main'; + } + return 'text.secondary'; + }; + + const getBorderColor = () => { + if (uploadState.error || (validation && !validation.isValid)) { + return 'error.main'; + } + if (uploadState.file && validation?.isValid) { + return 'success.main'; + } + if (isDragOver) { + return 'primary.main'; + } + return 'divider'; + }; + + return ( + + + + + + {getStatusIcon()} + + {!uploadState.file ? ( + <> + + {isDragOver ? 'Drop your plugin file here' : 'Upload Plugin Archive'} + + + Drag and drop your plugin archive file here, or click to browse + + + Supported formats: ZIP, RAR, TAR.GZ • Max size: {formatFileSize(maxSize)} + + + + ) : ( + <> + + + + {uploadState.file.name} + + + + + + + + + {validation && ( + + )} + + + {uploadState.uploading && ( + + + Uploading... {uploadState.progress}% + + + + )} + + )} + + + + {/* Validation Error */} + {validation && !validation.isValid && ( + + + File Validation Error + + + {validation.error} + + + )} + + {/* Upload Error */} + {uploadState.error && ( + + + Upload Error + + + {uploadState.error} + + + )} + + {/* Success Message */} + {uploadState.file && validation?.isValid && !uploadState.uploading && !uploadState.error && ( + + + File is ready for installation. Click "Install Plugin" to proceed. + + + )} + + ); +}; + +export default FileUploadZone; \ No newline at end of file diff --git a/frontend/src/features/plugin-installer/components/install-methods/GitHubInstallForm.tsx b/frontend/src/features/plugin-installer/components/install-methods/GitHubInstallForm.tsx new file mode 100644 index 0000000..a5d6b58 --- /dev/null +++ b/frontend/src/features/plugin-installer/components/install-methods/GitHubInstallForm.tsx @@ -0,0 +1,331 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + Box, + TextField, + Button, + FormControl, + InputLabel, + Select, + MenuItem, + Typography, + Paper, + Alert, + InputAdornment, + IconButton, + Tooltip, + CircularProgress +} from '@mui/material'; +import { + GitHub as GitHubIcon, + Help as HelpIcon, + Clear as ClearIcon +} from '@mui/icons-material'; +import { GitHubInstallRequest } from '../../types'; + +interface GitHubRelease { + tag_name: string; + name: string; + published_at: string; + prerelease: boolean; +} + +interface GitHubInstallFormProps { + onInstall: (request: GitHubInstallRequest) => void; + isInstalling: boolean; + onValidateUrl?: (url: string) => { isValid: boolean; error?: string }; +} + +const GitHubInstallForm: React.FC = ({ + onInstall, + isInstalling, + onValidateUrl +}) => { + const [repoUrl, setRepoUrl] = useState(''); + const [version, setVersion] = useState('latest'); + const [urlError, setUrlError] = useState(null); + const [releases, setReleases] = useState([]); + const [loadingReleases, setLoadingReleases] = useState(false); + const [isGitHubUrl, setIsGitHubUrl] = useState(false); + + // Helper function to check if URL is a GitHub URL + const isGitHubRepository = (url: string): boolean => { + return /^https?:\/\/github\.com\/[^\/]+\/[^\/]+/i.test(url); + }; + + // Helper function to extract owner/repo from GitHub URL + const extractGitHubInfo = (url: string): { owner: string; repo: string; tag?: string } | null => { + const match = url.match(/^https?:\/\/github\.com\/([^\/]+)\/([^\/]+)(?:\/releases\/tag\/([^\/]+))?/i); + if (match) { + return { + owner: match[1], + repo: match[2].replace(/\.git$/, ''), // Remove .git suffix if present + tag: match[3] + }; + } + return null; + }; + + // Helper function to get clean repository URL + const getCleanRepoUrl = (url: string): string => { + const info = extractGitHubInfo(url); + if (info) { + return `https://github.com/${info.owner}/${info.repo}`; + } + return url; + }; + + // Fetch GitHub releases + const fetchGitHubReleases = async (owner: string, repo: string): Promise => { + try { + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases`); + if (!response.ok) { + throw new Error(`Failed to fetch releases: ${response.statusText}`); + } + const releases = await response.json(); + return releases.filter((release: GitHubRelease) => !release.prerelease); + } catch (error) { + console.error('Error fetching GitHub releases:', error); + return []; + } + }; + + const handleUrlChange = useCallback(async (event: React.ChangeEvent) => { + const url = event.target.value; + + // Clear previous error and releases + setUrlError(null); + setReleases([]); + setLoadingReleases(false); + + // Check if it's a GitHub URL + const isGitHub = isGitHubRepository(url); + setIsGitHubUrl(isGitHub); + + // Determine the final URL and version to set + let finalUrl = url; + let finalVersion = 'latest'; + + // If it's a GitHub URL, handle tag URLs and clean the URL + if (url.trim() && isGitHub) { + const gitHubInfo = extractGitHubInfo(url); + if (gitHubInfo) { + // Always use the clean repository URL + finalUrl = getCleanRepoUrl(url); + + // If URL contains a specific tag, auto-select it + if (gitHubInfo.tag) { + finalVersion = gitHubInfo.tag; + } + } + } + + // Set the final URL and version + setRepoUrl(finalUrl); + setVersion(finalVersion); + + // Validate URL if provided + if (finalUrl.trim() && onValidateUrl) { + const validation = onValidateUrl(finalUrl); + if (!validation.isValid) { + setUrlError(validation.error || 'Invalid URL'); + return; + } + } + + // If it's a GitHub URL, fetch releases + if (finalUrl.trim() && isGitHub) { + const gitHubInfo = extractGitHubInfo(finalUrl); + if (gitHubInfo) { + // Fetch available releases + setLoadingReleases(true); + try { + const fetchedReleases = await fetchGitHubReleases(gitHubInfo.owner, gitHubInfo.repo); + setReleases(fetchedReleases); + + // If no specific tag was provided and we have releases, auto-select the latest + if (!gitHubInfo.tag && fetchedReleases.length > 0) { + setVersion(fetchedReleases[0].tag_name); + } + } catch (error) { + console.error('Failed to fetch releases:', error); + } finally { + setLoadingReleases(false); + } + } + } + }, [onValidateUrl]); + + const handleClearUrl = useCallback(() => { + setRepoUrl(''); + setUrlError(null); + setReleases([]); + setVersion('latest'); + setIsGitHubUrl(false); + setLoadingReleases(false); + }, []); + + const handleSubmit = useCallback((event: React.FormEvent) => { + event.preventDefault(); + + if (!repoUrl.trim()) { + setUrlError('Repository URL is required'); + return; + } + + if (onValidateUrl) { + const validation = onValidateUrl(repoUrl); + if (!validation.isValid) { + setUrlError(validation.error || 'Invalid URL'); + return; + } + } + + onInstall({ + method: 'github', + repo_url: getCleanRepoUrl(repoUrl.trim()), + version: version || 'latest' + }); + }, [repoUrl, version, onInstall, onValidateUrl]); + + const exampleUrls = [ + 'https://github.com/user/awesome-plugin', + 'https://github.com/company/weather-widget', + 'https://github.com/DJJones66/NetworkEyes/releases/tag/1.0.7' + ]; + + return ( + + + + + Install from GitHub Repository + + + Install plugins directly from GitHub repositories. The plugin will be downloaded and installed for your account only. + + + + + + + + ), + endAdornment: repoUrl && ( + + + + + + ) + }} + /> + + {repoUrl.trim() && ( + <> + + + Version + + + {loadingReleases && ( + + )} + + + {((version !== 'latest' && version === 'custom') || + (!isGitHubUrl && version !== 'latest') || + (isGitHubUrl && releases.length === 0 && version !== 'latest' && version !== 'custom')) && ( + setVersion(e.target.value || 'latest')} + disabled={isInstalling} + fullWidth + helperText="Enter the exact version tag from the repository releases" + /> + )} + + )} + + + + + Example URLs: + + {exampleUrls.map((url, index) => ( + !isInstalling && handleUrlChange({ target: { value: url } } as React.ChangeEvent)} + > + {url} + + ))} + + • Repository URLs will auto-fetch available releases +
    + • Release tag URLs will auto-select the specific version +
    +
    + + + +
    + + + + +
    +
    + ); +}; + +export default GitHubInstallForm; \ No newline at end of file diff --git a/frontend/src/features/plugin-installer/components/install-methods/InstallMethodTabs.tsx b/frontend/src/features/plugin-installer/components/install-methods/InstallMethodTabs.tsx new file mode 100644 index 0000000..4fbadcc --- /dev/null +++ b/frontend/src/features/plugin-installer/components/install-methods/InstallMethodTabs.tsx @@ -0,0 +1,218 @@ +import React, { useState, useCallback } from 'react'; +import { + Box, + Tabs, + Tab, + Typography, + Paper +} from '@mui/material'; +import { + GitHub as GitHubIcon, + InsertDriveFile as FileIcon, + Store as StoreIcon +} from '@mui/icons-material'; +import GitHubInstallForm from './GitHubInstallForm'; +import LocalFileInstallForm from './LocalFileInstallForm'; +import { PluginInstallRequest, InstallationMethod, InstallationMethodConfig } from '../../types'; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +const TabPanel: React.FC = ({ children, value, index, ...other }) => { + return ( + + ); +}; + +const a11yProps = (index: number) => { + return { + id: `install-method-tab-${index}`, + 'aria-controls': `install-method-tabpanel-${index}`, + }; +}; + +interface InstallMethodTabsProps { + onInstall: (request: PluginInstallRequest) => void; + isInstalling: boolean; + onValidateUrl?: (url: string) => { isValid: boolean; error?: string }; + defaultMethod?: InstallationMethod; +} + +const InstallMethodTabs: React.FC = ({ + onInstall, + isInstalling, + onValidateUrl, + defaultMethod = 'github' +}) => { + // Define available installation methods + const installationMethods: InstallationMethodConfig[] = [ + { + id: 'github', + label: 'GitHub Repository', + description: 'Install from GitHub repository URL', + icon: GitHubIcon + }, + { + id: 'local-file', + label: 'Local File', + description: 'Upload plugin archive from your computer', + icon: FileIcon + }, + // Future enhancement: Plugin marketplace + // { + // id: 'marketplace', + // label: 'Marketplace', + // description: 'Browse and install from plugin marketplace', + // icon: StoreIcon, + // disabled: true + // } + ]; + + // Find the default tab index + const defaultTabIndex = installationMethods.findIndex(method => method.id === defaultMethod); + const [selectedTab, setSelectedTab] = useState(defaultTabIndex >= 0 ? defaultTabIndex : 0); + + const handleTabChange = useCallback((event: React.SyntheticEvent, newValue: number) => { + if (!isInstalling) { + setSelectedTab(newValue); + } + }, [isInstalling]); + + const currentMethod = installationMethods[selectedTab]; + + return ( + + {/* Tab Header */} + + + + Choose Installation Method + + + Select how you want to install your plugin. Each method supports the same plugin format. + + + + + {installationMethods.map((method, index) => { + const IconComponent = method.icon; + return ( + } + label={ + + + {method.label} + + + {method.description} + + + } + disabled={method.disabled || isInstalling} + {...a11yProps(index)} + sx={{ + '&.Mui-selected': { + color: 'primary.main' + } + }} + /> + ); + })} + + + + {/* Tab Content */} + + + + + + + + + {/* Future: Marketplace tab */} + {/* + + */} + + {/* Installation Method Info */} + + + About {currentMethod.label} + + + {currentMethod.id === 'github' && ( + + + Install plugins directly from GitHub repositories. This method: + + +
  • Downloads the latest release or specified version
  • +
  • Supports both public and private repositories (with authentication)
  • +
  • Automatically validates plugin structure
  • +
  • Provides version management and update notifications
  • +
    +
    + )} + + {currentMethod.id === 'local-file' && ( + + + Upload plugin archive files from your computer. This method: + + +
  • Supports ZIP, RAR, and TAR.GZ archive formats
  • +
  • Validates file structure before installation
  • +
  • Ideal for testing custom or private plugins
  • +
  • Maximum file size: 100MB
  • +
    +
    + )} +
    +
    + ); +}; + +export default InstallMethodTabs; \ No newline at end of file diff --git a/frontend/src/features/plugin-installer/components/install-methods/LocalFileInstallForm.tsx b/frontend/src/features/plugin-installer/components/install-methods/LocalFileInstallForm.tsx new file mode 100644 index 0000000..eb6646b --- /dev/null +++ b/frontend/src/features/plugin-installer/components/install-methods/LocalFileInstallForm.tsx @@ -0,0 +1,160 @@ +import React, { useState, useCallback } from 'react'; +import { + Box, + Button, + Typography, + Paper, + Alert, + Chip +} from '@mui/material'; +import { + InsertDriveFile as FileIcon, + CloudUpload as CloudUploadIcon +} from '@mui/icons-material'; +import FileUploadZone from '../common/FileUploadZone'; +import { LocalFileInstallRequest, FileUploadState } from '../../types'; +import { formatFileSize } from '../../utils/fileValidation'; + +interface LocalFileInstallFormProps { + onInstall: (request: LocalFileInstallRequest) => void; + isInstalling: boolean; +} + +const LocalFileInstallForm: React.FC = ({ + onInstall, + isInstalling +}) => { + const [uploadState, setUploadState] = useState({ + file: null, + uploading: false, + progress: 0, + error: null + }); + + const handleFileSelect = useCallback((file: File) => { + setUploadState(prev => ({ + ...prev, + file, + error: null + })); + }, []); + + const handleFileRemove = useCallback(() => { + setUploadState({ + file: null, + uploading: false, + progress: 0, + error: null + }); + }, []); + + const handleSubmit = useCallback((event: React.FormEvent) => { + event.preventDefault(); + + if (!uploadState.file) { + setUploadState(prev => ({ + ...prev, + error: 'Please select a plugin archive file' + })); + return; + } + + onInstall({ + method: 'local-file', + file: uploadState.file, + filename: uploadState.file.name + }); + }, [uploadState.file, onInstall]); + + const canInstall = uploadState.file && !uploadState.error && !isInstalling; + + return ( + + + + + Install from Local File + + + Upload a plugin archive file (ZIP, RAR, or TAR.GZ) from your computer. The plugin will be extracted and installed for your account only. + + + + + + + {/* File Information */} + {uploadState.file && ( + + + Selected File: + + + + {uploadState.file.name} + + + + + + + )} + + + + Supported Archive Formats: + + +
  • ZIP - Most common format, widely supported
  • +
  • RAR - High compression ratio
  • +
  • TAR.GZ - Unix/Linux standard format
  • +
    + + • Maximum file size: 100MB +
    + • Archive must contain a valid plugin structure with plugin.json +
    + • Files will be validated before installation +
    +
    + + + + Security Notice: + + + Only install plugins from trusted sources. Malicious plugins can potentially harm your system or compromise your data. + Always verify the source and contents of plugin files before installation. + + + + + + +
    +
    + ); +}; + +export default LocalFileInstallForm; \ No newline at end of file diff --git a/frontend/src/features/plugin-installer/hooks/usePluginInstaller.ts b/frontend/src/features/plugin-installer/hooks/usePluginInstaller.ts index c686dba..f1d1bb9 100644 --- a/frontend/src/features/plugin-installer/hooks/usePluginInstaller.ts +++ b/frontend/src/features/plugin-installer/hooks/usePluginInstaller.ts @@ -1,6 +1,8 @@ import { useState, useCallback } from 'react'; import { PluginInstallRequest, + GitHubInstallRequest, + LocalFileInstallRequest, PluginInstallResponse, PluginInstallationState, InstallationStep, @@ -8,7 +10,7 @@ import { } from '../types'; import { pluginInstallerService } from '../services'; -const INSTALLATION_STEPS: Omit[] = [ +const GITHUB_INSTALLATION_STEPS: Omit[] = [ { id: 'validate', label: 'Validating repository URL' }, { id: 'download', label: 'Downloading plugin from repository' }, { id: 'extract', label: 'Extracting and validating plugin' }, @@ -16,20 +18,29 @@ const INSTALLATION_STEPS: Omit[] = [ { id: 'complete', label: 'Installation complete' } ]; +const FILE_INSTALLATION_STEPS: Omit[] = [ + { id: 'validate', label: 'Validating archive file' }, + { id: 'upload', label: 'Uploading plugin file' }, + { id: 'extract', label: 'Extracting and validating plugin' }, + { id: 'install', label: 'Installing plugin for your account' }, + { id: 'complete', label: 'Installation complete' } +]; + export const usePluginInstaller = () => { const [installationState, setInstallationState] = useState({ isInstalling: false, currentStep: 0, - steps: INSTALLATION_STEPS.map(step => ({ ...step, status: 'pending' })), + steps: GITHUB_INSTALLATION_STEPS.map(step => ({ ...step, status: 'pending' })), result: null, error: null }); - const resetInstallation = useCallback(() => { + const resetInstallation = useCallback((method: 'github' | 'local-file' = 'github') => { + const steps = method === 'github' ? GITHUB_INSTALLATION_STEPS : FILE_INSTALLATION_STEPS; setInstallationState({ isInstalling: false, currentStep: 0, - steps: INSTALLATION_STEPS.map(step => ({ ...step, status: 'pending' })), + steps: steps.map(step => ({ ...step, status: 'pending' })), result: null, error: null }); @@ -50,106 +61,147 @@ export const usePluginInstaller = () => { })); }, []); + const handleInstallationResult = useCallback((result: PluginInstallResponse): PluginInstallResponse => { + if (result.status === 'success') { + // Step 5: Complete + updateStep(4, 'completed', `Plugin "${result.data?.plugin_slug}" installed successfully!`); + setInstallationState(prev => ({ + ...prev, + isInstalling: false, + result + })); + } else { + // Determine which step failed based on the error details + let failedStep = 3; // Default to install step + let errorMessage = result.error || 'Installation failed'; + + // Check if we have detailed error information + const errorDetails = (result as any).errorDetails; + if (errorDetails?.step) { + switch (errorDetails.step) { + case 'url_parsing': + failedStep = 0; + break; + case 'release_lookup': + case 'download_and_extract': + case 'file_upload': + failedStep = 1; + break; + case 'plugin_validation': + case 'file_extraction': + failedStep = 2; + break; + case 'lifecycle_manager_install': + case 'lifecycle_manager_execution': + default: + failedStep = 3; + break; + } + } else { + // Fallback to text-based detection + if (result.error?.includes('repository') || result.error?.includes('download') || result.error?.includes('upload')) { + failedStep = 1; + } else if (result.error?.includes('extract') || result.error?.includes('validate')) { + failedStep = 2; + } + } + + // Create enhanced error message with suggestions if available + let enhancedError = errorMessage; + const suggestions = (result as any).suggestions; + if (suggestions && suggestions.length > 0) { + enhancedError += '\n\nSuggestions:\n' + suggestions.map((s: string) => `• ${s}`).join('\n'); + } + + updateStep(failedStep, 'error', undefined, enhancedError); + setInstallationState(prev => ({ + ...prev, + isInstalling: false, + error: enhancedError, + errorDetails: errorDetails, + suggestions: suggestions + })); + } + + return result; + }, [updateStep]); + + const handleGitHubInstallation = useCallback(async (request: GitHubInstallRequest): Promise => { + // Step 1: Validate URL + updateStep(0, 'in-progress', 'Checking repository URL format...'); + const validation = pluginInstallerService.validateGitHubUrl(request.repo_url); + if (!validation.isValid) { + updateStep(0, 'error', undefined, validation.error); + setInstallationState(prev => ({ ...prev, isInstalling: false, error: validation.error || 'Invalid URL' })); + return { + status: 'error', + message: 'URL validation failed', + error: validation.error + }; + } + updateStep(0, 'completed', 'Repository URL is valid'); + + // Step 2: Download + updateStep(1, 'in-progress', 'Contacting GitHub and downloading plugin...'); + + // Step 3: Extract (we'll update this during the API call) + updateStep(2, 'in-progress', 'Processing plugin files...'); + + // Step 4: Install + updateStep(3, 'in-progress', 'Installing plugin to your account...'); + + // Make the actual API call + const normalizedUrl = pluginInstallerService.normalizeGitHubUrl(request.repo_url); + const result = await pluginInstallerService.installPlugin({ + ...request, + repo_url: normalizedUrl + }); + + return handleInstallationResult(result); + }, [updateStep, handleInstallationResult]); + + const handleFileInstallation = useCallback(async (request: LocalFileInstallRequest): Promise => { + // Step 1: Validate file + updateStep(0, 'in-progress', 'Validating archive file...'); + // File validation is already done in the component, so we can mark as completed + updateStep(0, 'completed', 'Archive file is valid'); + + // Step 2: Upload + updateStep(1, 'in-progress', 'Uploading plugin file...'); + + // Step 3: Extract + updateStep(2, 'in-progress', 'Processing plugin files...'); + + // Step 4: Install + updateStep(3, 'in-progress', 'Installing plugin to your account...'); + + // Make the actual API call + const result = await pluginInstallerService.installPlugin(request); + + return handleInstallationResult(result); + }, [updateStep, handleInstallationResult]); + const installPlugin = useCallback(async (request: PluginInstallRequest): Promise => { try { + // Set up the appropriate steps based on installation method + const steps = request.method === 'github' ? GITHUB_INSTALLATION_STEPS : FILE_INSTALLATION_STEPS; + setInstallationState(prev => ({ ...prev, isInstalling: true, currentStep: 0, + steps: steps.map(step => ({ ...step, status: 'pending' })), result: null, error: null })); - // Step 1: Validate URL - updateStep(0, 'in-progress', 'Checking repository URL format...'); - const validation = pluginInstallerService.validateGitHubUrl(request.repo_url); - if (!validation.isValid) { - updateStep(0, 'error', undefined, validation.error); - setInstallationState(prev => ({ ...prev, isInstalling: false, error: validation.error || 'Invalid URL' })); - return { - status: 'error', - message: 'URL validation failed', - error: validation.error - }; - } - updateStep(0, 'completed', 'Repository URL is valid'); - - // Step 2: Download - updateStep(1, 'in-progress', 'Contacting GitHub and downloading plugin...'); - - // Step 3: Extract (we'll update this during the API call) - updateStep(2, 'in-progress', 'Processing plugin files...'); - - // Step 4: Install - updateStep(3, 'in-progress', 'Installing plugin to your account...'); - - // Make the actual API call - const normalizedUrl = pluginInstallerService.normalizeGitHubUrl(request.repo_url); - const result = await pluginInstallerService.installFromUrl({ - ...request, - repo_url: normalizedUrl - }); - - if (result.status === 'success') { - // Step 5: Complete - updateStep(4, 'completed', `Plugin "${result.data?.plugin_slug}" installed successfully!`); - setInstallationState(prev => ({ - ...prev, - isInstalling: false, - result - })); + if (request.method === 'github') { + return await handleGitHubInstallation(request); + } else if (request.method === 'local-file') { + return await handleFileInstallation(request); } else { - // Determine which step failed based on the error details - let failedStep = 3; // Default to install step - let errorMessage = result.error || 'Installation failed'; - - // Check if we have detailed error information - const errorDetails = (result as any).errorDetails; - if (errorDetails?.step) { - switch (errorDetails.step) { - case 'url_parsing': - failedStep = 0; - break; - case 'release_lookup': - case 'download_and_extract': - failedStep = 1; - break; - case 'plugin_validation': - failedStep = 2; - break; - case 'lifecycle_manager_install': - case 'lifecycle_manager_execution': - default: - failedStep = 3; - break; - } - } else { - // Fallback to text-based detection - if (result.error?.includes('repository') || result.error?.includes('download')) { - failedStep = 1; - } else if (result.error?.includes('extract') || result.error?.includes('validate')) { - failedStep = 2; - } - } - - // Create enhanced error message with suggestions if available - let enhancedError = errorMessage; - const suggestions = (result as any).suggestions; - if (suggestions && suggestions.length > 0) { - enhancedError += '\n\nSuggestions:\n' + suggestions.map((s: string) => `• ${s}`).join('\n'); - } - - updateStep(failedStep, 'error', undefined, enhancedError); - setInstallationState(prev => ({ - ...prev, - isInstalling: false, - error: enhancedError, - errorDetails: errorDetails, - suggestions: suggestions - })); + throw new Error(`Unsupported installation method: ${(request as any).method}`); } - - return result; } catch (error: any) { console.error('Plugin installation error:', error); updateStep(3, 'error', undefined, error.message || 'Unexpected error occurred'); @@ -165,7 +217,7 @@ export const usePluginInstaller = () => { error: error.message || 'Unexpected error occurred' }; } - }, [updateStep]); + }, [handleGitHubInstallation, handleFileInstallation, updateStep]); const getPluginStatus = useCallback(async (pluginSlug: string) => { return await pluginInstallerService.getPluginStatus(pluginSlug); diff --git a/frontend/src/features/plugin-installer/services/pluginInstallerService.ts b/frontend/src/features/plugin-installer/services/pluginInstallerService.ts index 75bc2e4..a23690c 100644 --- a/frontend/src/features/plugin-installer/services/pluginInstallerService.ts +++ b/frontend/src/features/plugin-installer/services/pluginInstallerService.ts @@ -1,11 +1,15 @@ import ApiService from '../../../services/ApiService'; import { PluginInstallRequest, + GitHubInstallRequest, + LocalFileInstallRequest, + LegacyPluginInstallRequest, PluginInstallResponse, AvailableUpdatesResponse, PluginTestResponse, FrontendTestResult, - ModuleInstantiationTest + ModuleInstantiationTest, + FileUploadProgress } from '../types'; import { remotePluginService } from '../../../services/remotePluginService'; import { registerRemotePlugins } from '../../../plugins'; @@ -14,13 +18,31 @@ class PluginInstallerService { private api = ApiService.getInstance(); /** - * Install a plugin from a remote repository URL + * Unified plugin installation method supporting both GitHub and local file uploads */ - async installFromUrl(request: PluginInstallRequest): Promise { + async installPlugin(request: PluginInstallRequest): Promise { + switch (request.method) { + case 'github': + return this.installFromGitHub(request); + case 'local-file': + return this.installFromFile(request); + default: + throw new Error(`Unsupported installation method: ${(request as any).method}`); + } + } + + /** + * Install a plugin from a GitHub repository + */ + private async installFromGitHub(request: GitHubInstallRequest): Promise { try { const response = await this.api.post( - '/api/v1/plugins/install-from-url', - request + '/api/v1/plugins/install', + { + method: 'github', + repo_url: request.repo_url, + version: request.version + } ); // If installation was successful, refresh the plugin registry @@ -78,6 +100,139 @@ class PluginInstallerService { } } + /** + * Install a plugin from a local file upload + */ + private async installFromFile(request: LocalFileInstallRequest): Promise { + try { + // Create FormData for file upload + const formData = new FormData(); + formData.append('file', request.file); + formData.append('method', 'local-file'); + formData.append('filename', request.filename); + + const response = await this.api.post( + '/api/v1/plugins/install', + formData, + { + headers: { + 'Content-Type': 'multipart/form-data' + } + } + ); + + // If installation was successful, refresh the plugin registry + if (response.status === 'success') { + await this.refreshPluginRegistry(); + } + + return response; + } catch (error: any) { + console.error('Plugin file installation failed:', error); + + // Extract detailed error information from the response + let errorMessage = 'Plugin installation failed'; + let errorDetails = null; + let suggestions: string[] = []; + + if (error.response?.data) { + const errorData = error.response.data; + + // Handle structured error response from our improved backend + if (typeof errorData === 'object' && errorData.message) { + errorMessage = errorData.message; + errorDetails = errorData.details; + suggestions = errorData.suggestions || []; + } else if (typeof errorData === 'object' && errorData.detail) { + // Handle FastAPI HTTPException format + if (typeof errorData.detail === 'object') { + errorMessage = errorData.detail.message || 'Installation failed'; + errorDetails = errorData.detail.details; + suggestions = errorData.detail.suggestions || []; + } else { + errorMessage = errorData.detail; + } + } else if (typeof errorData === 'string') { + errorMessage = errorData; + } + } else if (error.message) { + errorMessage = error.message; + } + + // Create enhanced error response + const errorResponse: PluginInstallResponse = { + status: 'error', + message: errorMessage, + error: errorMessage + }; + + // Add additional error context if available + if (errorDetails || suggestions.length > 0) { + (errorResponse as any).errorDetails = errorDetails; + (errorResponse as any).suggestions = suggestions; + } + + return errorResponse; + } + } + + /** + * Legacy method for backward compatibility + * @deprecated Use installPlugin instead + */ + async installFromUrl(request: LegacyPluginInstallRequest): Promise { + return this.installFromGitHub({ + method: 'github', + repo_url: request.repo_url, + version: request.version + }); + } + + /** + * Upload file with progress tracking + */ + async uploadFileWithProgress( + file: File, + onProgress?: (progress: FileUploadProgress) => void + ): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const formData = new FormData(); + formData.append('file', file); + + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable && onProgress) { + const progress: FileUploadProgress = { + loaded: event.loaded, + total: event.total, + percentage: Math.round((event.loaded / event.total) * 100) + }; + onProgress(progress); + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const response = JSON.parse(xhr.responseText); + resolve(response.file_id || response.filename); + } catch (error) { + reject(new Error('Invalid response format')); + } + } else { + reject(new Error(`Upload failed: ${xhr.statusText}`)); + } + }); + + xhr.addEventListener('error', () => { + reject(new Error('Upload failed')); + }); + + xhr.open('POST', '/api/v1/plugins/upload'); + xhr.send(formData); + }); + } + /** * Refresh the frontend plugin registry after installation */ @@ -317,7 +472,7 @@ class PluginInstallerService { let pluginManifest = manifest.find(p => p.id === pluginSlug); if (!pluginManifest) { // Also try to find by plugin_slug field if it exists - pluginManifest = manifest.find(p => p.plugin_slug === pluginSlug); + pluginManifest = manifest.find(p => (p as any).plugin_slug === pluginSlug); } if (!pluginManifest) { // Also try to find by name field as fallback @@ -327,7 +482,7 @@ class PluginInstallerService { if (!pluginManifest) { return { success: false, - error: `Plugin '${pluginSlug}' not found in manifest. Available plugins: ${manifest.map(p => `${p.id} (slug: ${p.plugin_slug || p.name})`).join(', ')}` + error: `Plugin '${pluginSlug}' not found in manifest. Available plugins: ${manifest.map(p => `${p.id} (slug: ${(p as any).plugin_slug || p.name})`).join(', ')}` }; } diff --git a/frontend/src/features/plugin-installer/types.ts b/frontend/src/features/plugin-installer/types.ts index c4ace9a..31f0a73 100644 --- a/frontend/src/features/plugin-installer/types.ts +++ b/frontend/src/features/plugin-installer/types.ts @@ -1,4 +1,26 @@ -export interface PluginInstallRequest { +// Base installation method types +export type InstallationMethod = 'github' | 'local-file'; + +export interface BaseInstallRequest { + method: InstallationMethod; +} + +export interface GitHubInstallRequest extends BaseInstallRequest { + method: 'github'; + repo_url: string; + version?: string; +} + +export interface LocalFileInstallRequest extends BaseInstallRequest { + method: 'local-file'; + file: File; + filename: string; +} + +export type PluginInstallRequest = GitHubInstallRequest | LocalFileInstallRequest; + +// Legacy support - keeping for backward compatibility during transition +export interface LegacyPluginInstallRequest { repo_url: string; version?: string; } @@ -12,8 +34,10 @@ export interface PluginInstallResponse { modules_created: string[]; plugin_directory: string; source: string; - repo_url: string; - version: string; + repo_url?: string; // Optional for file uploads + version?: string; // Optional for file uploads + filename?: string; // For file uploads + file_size?: number; // For file uploads }; error?: string; } @@ -50,6 +74,8 @@ export interface ErrorDetails { plugin_slug?: string; exception_type?: string; validation_error?: string; + filename?: string; // For file upload errors + file_size?: number; // For file upload errors } export interface PluginInstallationState { @@ -62,6 +88,27 @@ export interface PluginInstallationState { suggestions?: string[]; } +// File upload specific types +export interface FileUploadState { + file: File | null; + uploading: boolean; + progress: number; + error: string | null; +} + +export interface ArchiveValidationResult { + isValid: boolean; + format: 'zip' | 'rar' | 'tar.gz' | 'unknown'; + size: number; + error?: string; +} + +export interface FileUploadProgress { + loaded: number; + total: number; + percentage: number; +} + // Plugin Testing Types export interface PluginTestResponse { status: 'success' | 'error' | 'partial'; @@ -115,4 +162,20 @@ export interface PluginTestState { isLoading: boolean; result: PluginTestResponse | null; hasRun: boolean; -} \ No newline at end of file +} + +// Installation method configuration +export interface InstallationMethodConfig { + id: InstallationMethod; + label: string; + description: string; + icon: React.ComponentType; + disabled?: boolean; +} + +// File validation constants +export const SUPPORTED_ARCHIVE_FORMATS = ['.zip', '.rar', '.tar.gz', '.tgz'] as const; +export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB +export const MIN_FILE_SIZE = 1024; // 1KB + +export type SupportedArchiveFormat = typeof SUPPORTED_ARCHIVE_FORMATS[number]; \ No newline at end of file diff --git a/frontend/src/features/plugin-installer/utils/fileValidation.ts b/frontend/src/features/plugin-installer/utils/fileValidation.ts new file mode 100644 index 0000000..5ef6a9a --- /dev/null +++ b/frontend/src/features/plugin-installer/utils/fileValidation.ts @@ -0,0 +1,161 @@ +import { + ArchiveValidationResult, + SUPPORTED_ARCHIVE_FORMATS, + MAX_FILE_SIZE, + MIN_FILE_SIZE, + SupportedArchiveFormat +} from '../types'; + +/** + * Validates an uploaded archive file for plugin installation + */ +export const validateArchiveFile = (file: File): ArchiveValidationResult => { + // Check file size + if (file.size < MIN_FILE_SIZE) { + return { + isValid: false, + format: 'unknown', + size: file.size, + error: `File is too small. Minimum size is ${MIN_FILE_SIZE / 1024}KB` + }; + } + + if (file.size > MAX_FILE_SIZE) { + return { + isValid: false, + format: 'unknown', + size: file.size, + error: `File is too large. Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)}MB` + }; + } + + // Detect archive format + const format = detectArchiveFormat(file.name); + + if (format === 'unknown') { + return { + isValid: false, + format: 'unknown', + size: file.size, + error: `Unsupported file format. Supported formats: ${SUPPORTED_ARCHIVE_FORMATS.join(', ')}` + }; + } + + // Additional MIME type validation + const expectedMimeTypes = getMimeTypesForFormat(format); + if (expectedMimeTypes.length > 0 && !expectedMimeTypes.includes(file.type)) { + // Don't fail validation based on MIME type alone, as it can be unreliable + console.warn(`MIME type mismatch: expected ${expectedMimeTypes.join(' or ')}, got ${file.type}`); + } + + return { + isValid: true, + format, + size: file.size + }; +}; + +/** + * Detects archive format from filename + */ +export const detectArchiveFormat = (filename: string): ArchiveValidationResult['format'] => { + const lowerName = filename.toLowerCase(); + + if (lowerName.endsWith('.zip')) { + return 'zip'; + } + + if (lowerName.endsWith('.rar')) { + return 'rar'; + } + + if (lowerName.endsWith('.tar.gz') || lowerName.endsWith('.tgz')) { + return 'tar.gz'; + } + + return 'unknown'; +}; + +/** + * Gets expected MIME types for a given archive format + */ +export const getMimeTypesForFormat = (format: ArchiveValidationResult['format']): string[] => { + switch (format) { + case 'zip': + return ['application/zip', 'application/x-zip-compressed']; + case 'rar': + return ['application/vnd.rar', 'application/x-rar-compressed']; + case 'tar.gz': + return ['application/gzip', 'application/x-gzip', 'application/x-tar']; + default: + return []; + } +}; + +/** + * Formats file size for display + */ +export const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +/** + * Checks if a file extension is supported + */ +export const isSupportedArchiveFormat = (filename: string): boolean => { + const format = detectArchiveFormat(filename); + return format !== 'unknown'; +}; + +/** + * Gets file extension from filename + */ +export const getFileExtension = (filename: string): string => { + const lowerName = filename.toLowerCase(); + + if (lowerName.endsWith('.tar.gz')) { + return '.tar.gz'; + } + + const lastDot = filename.lastIndexOf('.'); + return lastDot !== -1 ? filename.substring(lastDot) : ''; +}; + +/** + * Validates multiple files for batch upload (future enhancement) + */ +export const validateMultipleFiles = (files: FileList | File[]): { + valid: File[]; + invalid: Array<{ file: File; error: string }>; +} => { + const valid: File[] = []; + const invalid: Array<{ file: File; error: string }> = []; + + Array.from(files).forEach(file => { + const validation = validateArchiveFile(file); + if (validation.isValid) { + valid.push(file); + } else { + invalid.push({ file, error: validation.error || 'Unknown validation error' }); + } + }); + + return { valid, invalid }; +}; + +/** + * Creates a safe filename for upload + */ +export const sanitizeFilename = (filename: string): string => { + // Remove or replace unsafe characters + return filename + .replace(/[^a-zA-Z0-9.-]/g, '_') + .replace(/_{2,}/g, '_') + .replace(/^_+|_+$/g, ''); +}; \ No newline at end of file From 098d0ef901477466cf1b1be98ddbc60720d895e4 Mon Sep 17 00:00:00 2001 From: David Jones Date: Sun, 13 Jul 2025 14:37:52 -0400 Subject: [PATCH 2/2] Installer issues --- backend/app/plugins/lifecycle_api.py | 206 +++++++++++++++++- backend/app/plugins/remote_installer.py | 201 +++++++++++++++++ .../services/pluginInstallerService.ts | 13 +- 3 files changed, 412 insertions(+), 8 deletions(-) diff --git a/backend/app/plugins/lifecycle_api.py b/backend/app/plugins/lifecycle_api.py index 3e5c7a2..b46a763 100644 --- a/backend/app/plugins/lifecycle_api.py +++ b/backend/app/plugins/lifecycle_api.py @@ -9,13 +9,15 @@ Now includes remote plugin installation from GitHub repositories. """ -from fastapi import APIRouter, HTTPException, Depends, status +from fastapi import APIRouter, HTTPException, Depends, status, File, UploadFile, Form from sqlalchemy.ext.asyncio import AsyncSession -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, Union from pathlib import Path import importlib.util import json import structlog +import tempfile +import shutil from pydantic import BaseModel # Import the remote installer @@ -45,11 +47,19 @@ def _get_error_suggestions(step: str, error_message: str) -> list: "Verify the release contains downloadable assets", "Ensure the release archive format is supported (tar.gz, zip)" ]) + elif step == 'file_extraction': + suggestions.extend([ + "Ensure the uploaded file is a valid archive (ZIP, TAR.GZ)", + "Check that the file is not corrupted", + "Verify the file size is within limits (100MB max)", + "Try re-uploading the file if extraction fails" + ]) elif step == 'plugin_validation': suggestions.extend([ "Ensure the plugin contains a 'lifecycle_manager.py' file", "Check that the lifecycle manager extends BaseLifecycleManager", - "Verify the plugin structure follows BrainDrive plugin standards" + "Verify the plugin structure follows BrainDrive plugin standards", + "Make sure the archive contains a valid BrainDrive plugin" ]) elif step == 'lifecycle_manager_install': suggestions.extend([ @@ -66,8 +76,8 @@ def _get_error_suggestions(step: str, error_message: str) -> list: else: suggestions.extend([ "Check the server logs for more detailed error information", - "Ensure the plugin repository follows BrainDrive plugin standards", - "Try installing a different version of the plugin" + "Ensure the plugin follows BrainDrive plugin standards", + "Try installing a different version or format of the plugin" ]) return suggestions @@ -77,6 +87,12 @@ class RemoteInstallRequest(BaseModel): repo_url: str version: str = "latest" +class UnifiedInstallRequest(BaseModel): + method: str # 'github' or 'local-file' + repo_url: Optional[str] = None + version: Optional[str] = "latest" + filename: Optional[str] = None + class UpdateCheckResponse(BaseModel): plugin_id: str current_version: str @@ -733,6 +749,186 @@ async def get_plugin_info(plugin_slug: str): ) # Remote plugin installation endpoints +@router.post("/install") +async def install_plugin_unified( + method: str = Form(...), + repo_url: Optional[str] = Form(None), + version: Optional[str] = Form("latest"), + filename: Optional[str] = Form(None), + file: Optional[UploadFile] = File(None), + current_user = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Unified plugin installation endpoint supporting both GitHub and local file methods. + + For GitHub installation: + - method: 'github' + - repo_url: GitHub repository URL + - version: Version to install (optional, defaults to 'latest') + + For local file installation: + - method: 'local-file' + - file: Archive file (ZIP, RAR, TAR.GZ) + - filename: Original filename + """ + try: + logger.info(f"Unified plugin installation requested by user {current_user.id}") + logger.info(f"Method: {method}") + + if method == 'github': + if not repo_url: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="repo_url is required for GitHub installation" + ) + + logger.info(f"GitHub installation - Repository URL: {repo_url}, Version: {version}") + + # Use the remote installer to install the plugin + result = await remote_installer.install_from_url( + repo_url=repo_url, + user_id=current_user.id, + version=version or "latest" + ) + + if result['success']: + return { + "status": "success", + "message": f"Plugin installed successfully from {repo_url}", + "data": { + "plugin_id": result.get('plugin_id'), + "plugin_slug": result.get('plugin_slug'), + "modules_created": result.get('modules_created', []), + "plugin_directory": result.get('plugin_directory'), + "source": "github", + "repo_url": repo_url, + "version": version or "latest" + } + } + else: + # Enhanced error response with suggestions + error_details = result.get('details', {}) + step = error_details.get('step', 'unknown') + error_message = result.get('error', 'Installation failed') + suggestions = _get_error_suggestions(step, error_message) + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "message": error_message, + "details": error_details, + "suggestions": suggestions + } + ) + + elif method == 'local-file': + if not file: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="file is required for local file installation" + ) + + logger.info(f"Local file installation - Filename: {filename}, Size: {file.size if hasattr(file, 'size') else 'unknown'}") + + # Validate file size (100MB limit) + MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB + if hasattr(file, 'size') and file.size > MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File size ({file.size} bytes) exceeds maximum allowed size ({MAX_FILE_SIZE} bytes)" + ) + + # Validate file format + if filename: + supported_formats = ['.zip', '.rar', '.tar.gz', '.tgz'] + file_ext = None + filename_lower = filename.lower() + for ext in supported_formats: + if filename_lower.endswith(ext): + file_ext = ext + break + + if not file_ext: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported file format. Supported formats: {', '.join(supported_formats)}" + ) + + # Save uploaded file to temporary location + import tempfile + import shutil + temp_dir = Path(tempfile.mkdtemp()) + temp_file_path = temp_dir / (filename or "uploaded_plugin") + + try: + # Write uploaded file to temporary location + with open(temp_file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + logger.info(f"File saved to temporary location: {temp_file_path}") + + # Use the remote installer to install from file + result = await remote_installer.install_from_file( + file_path=temp_file_path, + user_id=current_user.id, + filename=filename + ) + + if result['success']: + return { + "status": "success", + "message": f"Plugin '{filename}' installed successfully from local file", + "data": { + "plugin_id": result.get('plugin_id'), + "plugin_slug": result.get('plugin_slug'), + "modules_created": result.get('modules_created', []), + "plugin_directory": result.get('plugin_directory'), + "source": "local-file", + "filename": filename, + "file_size": temp_file_path.stat().st_size if temp_file_path.exists() else 0 + } + } + else: + # Enhanced error response with suggestions + error_details = result.get('details', {}) + step = error_details.get('step', 'unknown') + error_message = result.get('error', 'Installation failed') + suggestions = _get_error_suggestions(step, error_message) + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "message": error_message, + "details": error_details, + "suggestions": suggestions + } + ) + + finally: + # Clean up temporary file + try: + if temp_file_path.exists(): + temp_file_path.unlink() + temp_dir.rmdir() + except Exception as cleanup_error: + logger.warning(f"Failed to clean up temporary file: {cleanup_error}") + + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported installation method: {method}. Supported methods: 'github', 'local-file'" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Unexpected error during unified plugin installation: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal server error during plugin installation: {str(e)}" + ) + @router.post("/install-from-url") async def install_plugin_from_repository( request: RemoteInstallRequest, diff --git a/backend/app/plugins/remote_installer.py b/backend/app/plugins/remote_installer.py index 1d4bee5..6334fdf 100644 --- a/backend/app/plugins/remote_installer.py +++ b/backend/app/plugins/remote_installer.py @@ -47,6 +47,105 @@ def __init__(self, plugins_base_dir: str = None, temp_dir: str = None): self.github_repo_pattern = re.compile(r'github\.com/([^/]+)/([^/]+)') self.github_release_pattern = re.compile(r'github\.com/([^/]+)/([^/]+)/releases') + async def install_from_file(self, file_path: Path, user_id: str, filename: str) -> Dict[str, Any]: + """ + Install a plugin from a local file + + Args: + file_path: Path to the uploaded plugin file + user_id: User ID to install plugin for + filename: Original filename of the uploaded file + + Returns: + Dict with installation result + """ + try: + logger.info(f"Installing plugin from local file {filename} for user {user_id}") + + # Extract the uploaded file + extract_result = await self._extract_local_file(file_path, filename) + if not extract_result['success']: + logger.error(f"Extraction failed: {extract_result.get('error', 'Unknown extraction error')}") + return { + 'success': False, + 'error': f"Extraction failed: {extract_result.get('error', 'Unknown extraction error')}", + 'details': { + 'step': 'file_extraction', + 'filename': filename + } + } + + logger.info(f"Successfully extracted to: {extract_result['extracted_path']}") + + # Validate plugin structure + validation_result = await self._validate_plugin_structure(extract_result['extracted_path']) + if not validation_result['valid']: + await self._cleanup_temp_files(extract_result['extracted_path']) + error_msg = f"Plugin validation failed: {validation_result['error']}" + logger.error(error_msg) + return { + 'success': False, + 'error': error_msg, + 'details': { + 'step': 'plugin_validation', + 'validation_error': validation_result['error'] + } + } + + logger.info(f"Plugin validation successful. Plugin info: {validation_result['plugin_info']}") + + # Install plugin using lifecycle manager + install_result = await self._install_plugin_locally( + extract_result['extracted_path'], + validation_result['plugin_info'], + user_id + ) + + # Cleanup temporary files + await self._cleanup_temp_files(extract_result['extracted_path']) + + if install_result['success']: + logger.info(f"Plugin installation successful: {install_result}") + + # Store installation metadata for local file + try: + await self._store_local_file_metadata( + user_id, + install_result['plugin_id'], + filename, + validation_result['plugin_info'] + ) + logger.info("Installation metadata stored successfully") + except Exception as metadata_error: + logger.warning(f"Failed to store installation metadata: {metadata_error}") + # Don't fail the installation for metadata storage issues + + # Trigger plugin discovery to refresh the plugin manager cache + try: + await self._refresh_plugin_discovery(user_id) + logger.info("Plugin discovery refreshed successfully") + except Exception as discovery_error: + logger.warning(f"Failed to refresh plugin discovery: {discovery_error}") + # Don't fail the installation for discovery refresh issues + else: + logger.error(f"Plugin installation failed: {install_result}") + + return install_result + + except Exception as e: + error_msg = f"Unexpected error during plugin installation from file {filename}: {str(e)}" + logger.error(error_msg, exc_info=True) + return { + 'success': False, + 'error': error_msg, + 'details': { + 'step': 'unexpected_exception', + 'exception_type': type(e).__name__, + 'filename': filename, + 'user_id': user_id + } + } + async def install_from_url(self, repo_url: str, user_id: str, version: str = "latest") -> Dict[str, Any]: """ Install a plugin from a remote repository URL @@ -506,6 +605,78 @@ async def _download_and_extract(self, release_info: Dict[str, Any]) -> Dict[str, return {'success': False, 'error': f'Download and extraction failed: {str(e)}'} + async def _extract_local_file(self, file_path: Path, filename: str) -> Dict[str, Any]: + """Extract a local plugin file""" + extract_dir = None + try: + logger.info(f"Extracting local file {filename} from {file_path}") + + # Create temporary extraction directory + extract_dir = self.temp_dir / f"local_extract_{filename}_{Path(file_path).stem}" + extract_dir.mkdir(exist_ok=True) + + # Extract archive based on file extension + try: + if filename.lower().endswith(('.tar.gz', '.tgz')): + with tarfile.open(file_path, 'r:gz') as tar: + tar.extractall(extract_dir) + logger.info(f"Successfully extracted tar.gz archive") + elif filename.lower().endswith('.zip'): + with zipfile.ZipFile(file_path, 'r') as zip_file: + zip_file.extractall(extract_dir) + logger.info(f"Successfully extracted zip archive") + elif filename.lower().endswith('.rar'): + # Note: RAR extraction requires additional library (rarfile) + # For now, return an error for RAR files + logger.error(f"RAR extraction not yet implemented") + return {'success': False, 'error': 'RAR file extraction is not yet supported. Please use ZIP or TAR.GZ format.'} + else: + logger.error(f"Unsupported archive format: {filename}") + return {'success': False, 'error': f'Unsupported archive format: {filename}. Supported formats: ZIP, TAR.GZ'} + except tarfile.TarError as e: + logger.error(f"Error extracting tar archive: {e}") + return {'success': False, 'error': f'Failed to extract tar archive: {str(e)}'} + except zipfile.BadZipFile as e: + logger.error(f"Error extracting zip archive: {e}") + return {'success': False, 'error': f'Failed to extract zip archive: {str(e)}'} + except Exception as e: + logger.error(f"Error during archive extraction: {e}") + return {'success': False, 'error': f'Archive extraction failed: {str(e)}'} + + # Find the actual plugin directory (may be nested) + plugin_dir = self._find_plugin_directory(extract_dir) + if not plugin_dir: + # List contents for debugging + try: + contents = list(extract_dir.rglob('*')) + logger.error(f"Could not find plugin directory. Archive contents: {[str(p.relative_to(extract_dir)) for p in contents[:10]]}") + except Exception: + logger.error("Could not find plugin directory and failed to list archive contents") + + return { + 'success': False, + 'error': 'Could not find plugin directory in archive. Archive may not contain a valid BrainDrive plugin.' + } + + logger.info(f"Found plugin directory: {plugin_dir}") + + return { + 'success': True, + 'extracted_path': plugin_dir, + 'extract_dir': extract_dir + } + + except Exception as e: + logger.error(f"Unexpected error extracting local file: {e}", exc_info=True) + # Clean up on error + if extract_dir and extract_dir.exists(): + try: + shutil.rmtree(extract_dir) + except Exception as cleanup_error: + logger.error(f"Failed to cleanup extraction directory: {cleanup_error}") + + return {'success': False, 'error': f'File extraction failed: {str(e)}'} + def _find_plugin_directory(self, extract_dir: Path) -> Optional[Path]: """Find the actual plugin directory within extracted archive""" # Look for directory containing lifecycle_manager.py or package.json @@ -860,6 +1031,36 @@ async def _get_installation_metadata(self, user_id: str, plugin_id: str) -> Opti logger.error(f"Error getting installation metadata: {e}") return None + async def _store_local_file_metadata(self, user_id: str, plugin_id: str, filename: str, plugin_info: Dict[str, Any]): + """Store metadata about a local file installation""" + try: + metadata_dir = self.plugins_base_dir / user_id / ".metadata" + metadata_dir.mkdir(parents=True, exist_ok=True) + + metadata_file = metadata_dir / f"{plugin_id}_local.json" + + metadata = { + 'plugin_id': plugin_id, + 'user_id': user_id, + 'filename': filename, + 'plugin_info': plugin_info, + 'installed_at': str(Path().cwd()), # Current timestamp would be better + 'installation_type': 'local', + 'source': 'local-file' + } + + # Add current timestamp + from datetime import datetime + metadata['installed_at'] = datetime.utcnow().isoformat() + + with open(metadata_file, 'w') as f: + json.dump(metadata, f, indent=2) + + logger.info(f"Stored local file installation metadata for {plugin_id}") + + except Exception as e: + logger.error(f"Error storing local file installation metadata: {e}") + async def _cleanup_temp_files(self, temp_path: Path): """Clean up temporary files and directories""" try: diff --git a/frontend/src/features/plugin-installer/services/pluginInstallerService.ts b/frontend/src/features/plugin-installer/services/pluginInstallerService.ts index a23690c..9c3a124 100644 --- a/frontend/src/features/plugin-installer/services/pluginInstallerService.ts +++ b/frontend/src/features/plugin-installer/services/pluginInstallerService.ts @@ -36,12 +36,19 @@ class PluginInstallerService { */ private async installFromGitHub(request: GitHubInstallRequest): Promise { try { + // Create FormData for GitHub installation to match backend expectations + const formData = new FormData(); + formData.append('method', 'github'); + formData.append('repo_url', request.repo_url); + formData.append('version', request.version || 'latest'); + const response = await this.api.post( '/api/v1/plugins/install', + formData, { - method: 'github', - repo_url: request.repo_url, - version: request.version + headers: { + 'Content-Type': 'multipart/form-data' + } } );