Hybrid Plugins
Hybrid plugins combine multiple plugin types into a single comprehensive solution, providing users with dashboard widgets, CLI commands, background services, and page extensions all in one package.
Table of Contents
- Hybrid Plugin Fundamentals
- Architecture Design
- Shared Code and Utilities
- Multi-Entry Point Management
- Configuration Management
- Testing Hybrid Plugins
Hybrid Plugin Fundamentals
What are Hybrid Plugins?
Hybrid plugins are comprehensive solutions that:
- Combine dashboard widgets, CLI tools, and background services
- Share common functionality across different interfaces
- Provide unified user experience across platforms
- Offer complete feature sets for complex use cases
Manifest Configuration
{
"type": "hybrid",
"dashboard_widget": {
"name": "Project Manager",
"component": "ProjectWidget",
"defaultSize": { "width": 500, "height": 400 }
},
"commands": [
{
"name": "project",
"description": "Manage projects from CLI",
"usage": "project <action> [options]"
}
],
"pages": [
{
"name": "projects",
"path": "/plugins/project-manager",
"component": "ProjectsPage",
"title": "Project Manager"
}
],
"background": "dist/background.js",
"main": "dist/index.js",
"web": "dist/web.js"
}
Architecture Design
Core Plugin Class
import { BasePlugin, PluginRuntimeContext } from '@qirvo/plugin-sdk';
import { ProjectService } from './services/ProjectService';
import { NotificationService } from './services/NotificationService';
export default class ProjectManagerPlugin extends BasePlugin {
private projectService: ProjectService;
private notificationService: NotificationService;
private backgroundService?: BackgroundProjectService;
async onInstall(context: PluginRuntimeContext): Promise<void> {
this.log('info', 'Installing Project Manager plugin');
// Initialize shared services
this.projectService = new ProjectService(context);
this.notificationService = new NotificationService(context);
// Set up initial data
await this.setupInitialData();
}
async onEnable(context: PluginRuntimeContext): Promise<void> {
this.log('info', 'Enabling Project Manager plugin');
// Initialize services
await this.projectService.initialize();
// Start background service if configured
const config = context.config as ProjectManagerConfig;
if (config.enableBackgroundSync) {
this.backgroundService = new BackgroundProjectService(
this.projectService,
this.notificationService
);
await this.backgroundService.start();
}
}
async onDisable(): Promise<void> {
this.log('info', 'Disabling Project Manager plugin');
// Stop background service
if (this.backgroundService) {
await this.backgroundService.stop();
this.backgroundService = undefined;
}
// Cleanup services
await this.projectService.cleanup();
}
// Public API for CLI and widget access
getProjectService(): ProjectService {
return this.projectService;
}
getNotificationService(): NotificationService {
return this.notificationService;
}
}
interface ProjectManagerConfig {
enableBackgroundSync: boolean;
syncInterval: number;
defaultProjectTemplate: string;
notificationSettings: {
deadlineReminders: boolean;
statusUpdates: boolean;
};
}
Shared Services Architecture
// services/ProjectService.ts
export class ProjectService {
constructor(private context: PluginRuntimeContext) {}
async createProject(data: CreateProjectData): Promise<Project> {
const project: Project = {
id: this.generateId(),
name: data.name,
description: data.description,
status: 'active',
createdAt: new Date(),
tasks: [],
members: data.members || []
};
// Save to storage
await this.saveProject(project);
// Emit event for other components
this.context.bus.emit('project.created', { project });
return project;
}
async getProjects(filter?: ProjectFilter): Promise<Project[]> {
const allProjects = await this.context.storage.get('projects') || [];
if (!filter) return allProjects;
return allProjects.filter(project => this.matchesFilter(project, filter));
}
async updateProject(id: string, updates: Partial<Project>): Promise<Project> {
const projects = await this.getProjects();
const projectIndex = projects.findIndex(p => p.id === id);
if (projectIndex === -1) {
throw new Error(`Project ${id} not found`);
}
const updatedProject = { ...projects[projectIndex], ...updates };
projects[projectIndex] = updatedProject;
await this.context.storage.set('projects', projects);
this.context.bus.emit('project.updated', { project: updatedProject });
return updatedProject;
}
async deleteProject(id: string): Promise<void> {
const projects = await this.getProjects();
const filteredProjects = projects.filter(p => p.id !== id);
await this.context.storage.set('projects', filteredProjects);
this.context.bus.emit('project.deleted', { projectId: id });
}
private async saveProject(project: Project): Promise<void> {
const projects = await this.getProjects();
projects.push(project);
await this.context.storage.set('projects', projects);
}
private generateId(): string {
return `project_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
private matchesFilter(project: Project, filter: ProjectFilter): boolean {
if (filter.status && project.status !== filter.status) return false;
if (filter.name && !project.name.toLowerCase().includes(filter.name.toLowerCase())) return false;
return true;
}
}
interface Project {
id: string;
name: string;
description: string;
status: 'active' | 'completed' | 'archived';
createdAt: Date;
tasks: Task[];
members: string[];
}
interface CreateProjectData {
name: string;
description: string;
members?: string[];
}
interface ProjectFilter {
status?: string;
name?: string;
}
Shared Code and Utilities
Common Utilities
// utils/common.ts
export class DateUtils {
static formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
static isOverdue(dueDate: Date): boolean {
return new Date() > dueDate;
}
static getDaysUntilDue(dueDate: Date): number {
const now = new Date();
const diffTime = dueDate.getTime() - now.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
}
export class ValidationUtils {
static validateProjectName(name: string): string[] {
const errors: string[] = [];
if (!name || name.trim().length === 0) {
errors.push('Project name is required');
}
if (name.length > 100) {
errors.push('Project name must be less than 100 characters');
}
return errors;
}
static validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}
export class FormatUtils {
static truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
static formatFileSize(bytes: number): string {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
}
Event System Integration
// services/EventBus.ts
export class PluginEventBus {
constructor(private context: PluginRuntimeContext) {}
// Emit events that all components can listen to
emitProjectEvent(event: ProjectEvent): void {
this.context.bus.emit(`project.${event.type}`, event.data);
// Also emit to global plugin event
this.context.bus.emit('plugin.project-manager.event', {
type: event.type,
data: event.data,
timestamp: new Date()
});
}
// Subscribe to events from any component
onProjectEvent(eventType: string, handler: (data: any) => void): () => void {
return this.context.bus.on(`project.${eventType}`, handler);
}
// Subscribe to external events
onExternalEvent(eventType: string, handler: (data: any) => void): () => void {
return this.context.bus.on(eventType, handler);
}
}
interface ProjectEvent {
type: 'created' | 'updated' | 'deleted' | 'status_changed';
data: any;
}
Multi-Entry Point Management
CLI Commands
// cli/commands.ts
import { createCommand } from '@qirvo/plugin-sdk';
import { ProjectService } from '../services/ProjectService';
export const projectCommands = [
createCommand(
'project',
'Manage projects from command line',
async (args: string[], context) => {
const plugin = context.plugin as ProjectManagerPlugin;
const projectService = plugin.getProjectService();
const action = args[0];
switch (action) {
case 'list':
await listProjects(projectService, args.slice(1));
break;
case 'create':
await createProject(projectService, args.slice(1));
break;
case 'update':
await updateProject(projectService, args.slice(1));
break;
case 'delete':
await deleteProject(projectService, args.slice(1));
break;
default:
console.log('Usage: project <list|create|update|delete> [options]');
}
}
)
];
async function listProjects(service: ProjectService, args: string[]): Promise<void> {
try {
const projects = await service.getProjects();
if (projects.length === 0) {
console.log('No projects found.');
return;
}
console.log('\nProjects:');
projects.forEach((project, index) => {
const status = project.status === 'active' ? '🟢' :
project.status === 'completed' ? '✅' : '📦';
console.log(`${index + 1}. ${status} ${project.name} (${project.tasks.length} tasks)`);
});
} catch (error) {
console.error('Failed to list projects:', error.message);
}
}
async function createProject(service: ProjectService, args: string[]): Promise<void> {
const name = args.join(' ');
if (!name) {
console.error('Project name is required');
return;
}
try {
const project = await service.createProject({
name,
description: '',
members: []
});
console.log(`✅ Project "${project.name}" created successfully`);
} catch (error) {
console.error('Failed to create project:', error.message);
}
}
Dashboard Widget
// components/ProjectWidget.tsx
import React, { useState, useEffect } from 'react';
import { ProjectService } from '../services/ProjectService';
interface ProjectWidgetProps {
plugin: ProjectManagerPlugin;
config: ProjectManagerConfig;
}
export const ProjectWidget: React.FC<ProjectWidgetProps> = ({ plugin, config }) => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const projectService = plugin.getProjectService();
useEffect(() => {
loadProjects();
// Listen for project events
const unsubscribe = plugin.getEventBus().onProjectEvent('*', () => {
loadProjects(); // Refresh on any project change
});
return unsubscribe;
}, []);
const loadProjects = async () => {
try {
setLoading(true);
const projectList = await projectService.getProjects();
setProjects(projectList);
} catch (error) {
console.error('Failed to load projects:', error);
} finally {
setLoading(false);
}
};
const handleCreateProject = async () => {
const name = prompt('Enter project name:');
if (!name) return;
try {
await projectService.createProject({
name,
description: '',
members: []
});
// Projects will auto-refresh via event listener
} catch (error) {
alert('Failed to create project: ' + error.message);
}
};
if (loading) {
return (
<div className="project-widget loading">
<div className="spinner"></div>
<p>Loading projects...</p>
</div>
);
}
return (
<div className="project-widget">
<div className="widget-header">
<h3>Projects</h3>
<button onClick={handleCreateProject} className="create-btn">
+ New
</button>
</div>
<div className="project-list">
{projects.length === 0 ? (
<p className="no-projects">No projects yet. Create your first project!</p>
) : (
projects.map(project => (
<div
key={project.id}
className={`project-item ${selectedProject?.id === project.id ? 'selected' : ''}`}
onClick={() => setSelectedProject(project)}
>
<div className="project-info">
<h4>{project.name}</h4>
<p>{project.tasks.length} tasks</p>
</div>
<div className={`status-badge ${project.status}`}>
{project.status}
</div>
</div>
))
)}
</div>
{selectedProject && (
<div className="project-details">
<h4>{selectedProject.name}</h4>
<p>{selectedProject.description}</p>
<div className="project-stats">
<span>Tasks: {selectedProject.tasks.length}</span>
<span>Members: {selectedProject.members.length}</span>
</div>
</div>
)}
</div>
);
};
Background Service
// services/BackgroundProjectService.ts
export class BackgroundProjectService {
private isRunning = false;
private syncInterval?: NodeJS.Timeout;
constructor(
private projectService: ProjectService,
private notificationService: NotificationService
) {}
async start(): Promise<void> {
this.isRunning = true;
// Start periodic sync
this.syncInterval = setInterval(async () => {
if (this.isRunning) {
await this.performSync();
}
}, 300000); // Every 5 minutes
// Start deadline monitoring
this.startDeadlineMonitoring();
}
async stop(): Promise<void> {
this.isRunning = false;
if (this.syncInterval) {
clearInterval(this.syncInterval);
this.syncInterval = undefined;
}
}
private async performSync(): Promise<void> {
try {
console.log('Performing background project sync...');
const projects = await this.projectService.getProjects();
// Sync with external services
for (const project of projects) {
await this.syncProjectWithExternal(project);
}
console.log('Background sync completed');
} catch (error) {
console.error('Background sync failed:', error);
}
}
private async syncProjectWithExternal(project: Project): Promise<void> {
// Implementation for syncing with external project management tools
// This could integrate with GitHub, Jira, Trello, etc.
}
private startDeadlineMonitoring(): void {
setInterval(async () => {
if (!this.isRunning) return;
await this.checkDeadlines();
}, 3600000); // Every hour
}
private async checkDeadlines(): Promise<void> {
try {
const projects = await this.projectService.getProjects();
for (const project of projects) {
for (const task of project.tasks) {
if (task.dueDate && this.isDeadlineApproaching(task.dueDate)) {
await this.notificationService.sendDeadlineReminder(project, task);
}
}
}
} catch (error) {
console.error('Deadline monitoring failed:', error);
}
}
private isDeadlineApproaching(dueDate: Date): boolean {
const now = new Date();
const timeDiff = dueDate.getTime() - now.getTime();
const hoursUntilDue = timeDiff / (1000 * 60 * 60);
return hoursUntilDue <= 24 && hoursUntilDue > 0;
}
}
Configuration Management
Unified Configuration Schema
{
"config_schema": {
"type": "object",
"properties": {
"general": {
"type": "object",
"title": "General Settings",
"properties": {
"defaultView": {
"type": "string",
"title": "Default View",
"enum": ["list", "board", "timeline"],
"default": "list"
},
"theme": {
"type": "string",
"title": "Theme",
"enum": ["light", "dark", "auto"],
"default": "auto"
}
}
},
"widget": {
"type": "object",
"title": "Widget Settings",
"properties": {
"showCompletedProjects": {
"type": "boolean",
"title": "Show Completed Projects",
"default": false
},
"maxProjectsDisplayed": {
"type": "number",
"title": "Max Projects in Widget",
"minimum": 1,
"maximum": 20,
"default": 5
}
}
},
"cli": {
"type": "object",
"title": "CLI Settings",
"properties": {
"outputFormat": {
"type": "string",
"title": "Default Output Format",
"enum": ["table", "json", "csv"],
"default": "table"
},
"confirmDestructiveActions": {
"type": "boolean",
"title": "Confirm Destructive Actions",
"default": true
}
}
},
"background": {
"type": "object",
"title": "Background Service",
"properties": {
"enableBackgroundSync": {
"type": "boolean",
"title": "Enable Background Sync",
"default": true
},
"syncInterval": {
"type": "number",
"title": "Sync Interval (minutes)",
"minimum": 5,
"maximum": 1440,
"default": 30
},
"deadlineReminders": {
"type": "boolean",
"title": "Deadline Reminders",
"default": true
}
}
}
}
}
}
Configuration Access
// services/ConfigService.ts
export class ConfigService {
constructor(private context: PluginRuntimeContext) {}
getGeneralConfig(): GeneralConfig {
return this.context.config.general || {};
}
getWidgetConfig(): WidgetConfig {
return this.context.config.widget || {};
}
getCLIConfig(): CLIConfig {
return this.context.config.cli || {};
}
getBackgroundConfig(): BackgroundConfig {
return this.context.config.background || {};
}
// Type-safe configuration access
get<T>(path: string): T | undefined {
return this.getNestedValue(this.context.config, path);
}
private getNestedValue(obj: any, path: string): any {
return path.split('.').reduce((current, key) => current?.[key], obj);
}
}
interface GeneralConfig {
defaultView?: 'list' | 'board' | 'timeline';
theme?: 'light' | 'dark' | 'auto';
}
interface WidgetConfig {
showCompletedProjects?: boolean;
maxProjectsDisplayed?: number;
}
interface CLIConfig {
outputFormat?: 'table' | 'json' | 'csv';
confirmDestructiveActions?: boolean;
}
interface BackgroundConfig {
enableBackgroundSync?: boolean;
syncInterval?: number;
deadlineReminders?: boolean;
}
Testing Hybrid Plugins
Component Testing
// tests/ProjectWidget.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ProjectWidget } from '../components/ProjectWidget';
import { createMockPlugin } from '../test-utils/mockPlugin';
describe('ProjectWidget', () => {
let mockPlugin: ProjectManagerPlugin;
let mockConfig: ProjectManagerConfig;
beforeEach(() => {
mockPlugin = createMockPlugin();
mockConfig = {
enableBackgroundSync: true,
syncInterval: 30,
defaultProjectTemplate: 'basic',
notificationSettings: {
deadlineReminders: true,
statusUpdates: true
}
};
});
it('renders project list correctly', async () => {
const mockProjects = [
{ id: '1', name: 'Test Project', status: 'active', tasks: [] }
];
mockPlugin.getProjectService().getProjects.mockResolvedValue(mockProjects);
render(<ProjectWidget plugin={mockPlugin} config={mockConfig} />);
await waitFor(() => {
expect(screen.getByText('Test Project')).toBeInTheDocument();
});
});
it('creates new project when button clicked', async () => {
render(<ProjectWidget plugin={mockPlugin} config={mockConfig} />);
// Mock prompt
global.prompt = jest.fn().mockReturnValue('New Project');
const createButton = screen.getByText('+ New');
fireEvent.click(createButton);
await waitFor(() => {
expect(mockPlugin.getProjectService().createProject).toHaveBeenCalledWith({
name: 'New Project',
description: '',
members: []
});
});
});
});
Integration Testing
// tests/integration/hybrid-plugin.test.ts
import { ProjectManagerPlugin } from '../src/index';
import { createTestContext } from '@qirvo/plugin-test-utils';
describe('Hybrid Plugin Integration', () => {
let plugin: ProjectManagerPlugin;
let context: PluginRuntimeContext;
beforeEach(async () => {
context = createTestContext();
plugin = new ProjectManagerPlugin();
await plugin.onInstall(context);
await plugin.onEnable(context);
});
afterEach(async () => {
await plugin.onDisable();
});
it('should share data between CLI and widget', async () => {
// Create project via service (simulating CLI)
const projectService = plugin.getProjectService();
const project = await projectService.createProject({
name: 'Integration Test Project',
description: 'Test project for integration',
members: []
});
// Verify project is available to widget
const projects = await projectService.getProjects();
expect(projects).toContainEqual(expect.objectContaining({
name: 'Integration Test Project'
}));
// Verify event was emitted
expect(context.bus.emit).toHaveBeenCalledWith('project.created', {
project: expect.objectContaining({ name: 'Integration Test Project' })
});
});
it('should handle configuration changes across components', async () => {
const oldConfig = { enableBackgroundSync: false };
const newConfig = { enableBackgroundSync: true };
await plugin.onConfigChange(
{ ...context, config: newConfig },
oldConfig
);
// Verify background service started
expect(plugin.backgroundService).toBeDefined();
});
});
Best Practices
Architecture
- Shared Services: Create reusable services for common functionality
- Event-Driven: Use events for communication between components
- Configuration: Provide unified configuration for all components
- Error Handling: Implement consistent error handling across all entry points
Development
- Code Reuse: Maximize shared code between components
- Type Safety: Use TypeScript interfaces for all shared data
- Testing: Test each component individually and integration scenarios
- Documentation: Document APIs used by multiple components
User Experience
- Consistency: Maintain consistent behavior across all interfaces
- Data Sync: Ensure data changes are reflected in all components
- Performance: Optimize shared services for multiple access patterns
- Configuration: Provide intuitive configuration options for all features
note
This completes the plugin-types documentation series. Each guide provides comprehensive coverage of its respective plugin type, with practical examples and best practices for building robust Qirvo plugins.