//! Plugin application use cases. use std::collections::HashSet; use std::sync::Arc; use domain::ports::{ EventBus, LocalPath, PluginManifestBytes, PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, }; use domain::{ ContentHash, DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, PluginInstallSource, PluginLifecycleState, PluginManifest, PluginMcpServerSpec, PluginRegistryEntry, PluginTrustLevel, RemovalOutcome, StagedPluginPackage, }; use serde::{Deserialize, Serialize}; use crate::AppError; /// Contribution counts for admin display. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginContributionSummary { /// Top-level menu count. pub top_level_menus: usize, /// Menu item count. pub menu_items: usize, /// Layout count. pub layouts: usize, /// MCP server count. pub mcp_servers: usize, } impl From<&PluginContributionSet> for PluginContributionSummary { fn from(c: &PluginContributionSet) -> Self { Self { top_level_menus: c.menus.len(), menu_items: c.menu_items.len(), layouts: c.layouts.len(), mcp_servers: c.mcp_servers.len(), } } } /// Admin plugin view. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginAdmin { /// Plugin id. pub id: String, /// Display name. pub display_name: String, /// Publisher. pub publisher: Option, /// Version. pub version: String, /// Description. pub description: Option, /// Icon URL. pub icon_url: Option, /// Source kind. pub source_kind: String, /// Source label. pub source_label: Option, /// Lifecycle state. pub lifecycle_state: PluginLifecycleState, /// Enabled projection. pub enabled: bool, /// Pending enable state. pub pending_enable_state: Option, /// Pending uninstall flag. pub pending_uninstall: bool, /// Restart required flag. pub restart_required: bool, /// Trust level. pub trust_level: PluginTrustLevel, /// Summary. pub contribution_summary: PluginContributionSummary, /// Error. pub error: Option, } /// Pre-install package review. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginReview { /// Manifest. pub manifest: PluginManifest, /// Source. pub source: PluginInstallSource, /// Content hash. pub content_hash: String, /// Summary. pub contribution_summary: PluginContributionSummary, /// Full-trust marker. pub trust_level: PluginTrustLevel, } /// Install result. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginInstallResult { /// Installed plugin. pub plugin: PluginAdmin, /// Review used for installation. pub review: PluginReview, /// Restart required flag. pub restart_required: bool, } /// Uninstall result. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UninstallPluginResult { /// Plugin id. pub plugin_id: String, /// Removal outcome. pub removal_outcome: RemovalOutcome, /// Restart required flag. pub restart_required: bool, } /// Runtime catalog. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginRuntimeCatalog { /// Runtime plugins. pub plugins: Vec, } /// Runtime plugin entry. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginRuntimePlugin { /// Plugin id. pub id: String, /// Display name. pub display_name: String, /// Publisher. pub publisher: Option, /// Version. pub version: String, /// Bundle URL. pub bundle_url: String, /// Icon URL. pub icon_url: Option, /// Content hash. pub content_hash: String, /// Contributions. pub contributes: PluginContributionSet, } /// Input for reviewing a package. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReviewPluginPackageInput { /// Review local archive. Archive { /// Path. path: String, }, /// Review local directory. Directory { /// Path. path: String, }, } fn map_store(e: PluginStoreError) -> AppError { match e { PluginStoreError::NotFound => AppError::NotFound("plugin package".to_owned()), PluginStoreError::Invalid(m) | PluginStoreError::Format(m) => AppError::Invalid(m), PluginStoreError::Io(m) => AppError::FileSystem(m), } } fn map_registry(e: PluginRegistryError) -> AppError { match e { PluginRegistryError::Io(m) => AppError::Store(m), PluginRegistryError::Serialization(m) => AppError::Store(m), } } fn map_manifest(e: PluginManifestError) -> AppError { match e { PluginManifestError::Json(m) | PluginManifestError::Invalid(m) | PluginManifestError::IncompatibleEngine(m) => AppError::Invalid(m), } } fn map_mcp(e: PluginMcpError) -> AppError { AppError::Process(e.to_string()) } fn plugin_package_ref(id: &PluginId) -> domain::PluginPackageRef { domain::PluginPackageRef { plugin_id: Some(id.clone()), root: id.as_str().to_owned(), } } fn admin_from_descriptor( d: PluginDescriptor, _packages: &dyn PluginPackageStore, ) -> Result { let icon_url = match &d.manifest.icon { Some(icon) => Some(plugin_asset_url( &d.manifest.id, d.manifest.version.as_str(), &d.registry.content_hash, icon, )), None => None, }; Ok(PluginAdmin { id: d.manifest.id.as_str().to_owned(), display_name: d.manifest.display_name, publisher: d.manifest.publisher, version: d.manifest.version.as_str().to_owned(), description: d.manifest.description, icon_url, source_kind: d.registry.source.kind().to_owned(), source_label: Some(d.registry.source.label().to_owned()), lifecycle_state: d.registry.lifecycle_state, enabled: matches!( d.registry.lifecycle_state, PluginLifecycleState::Enabled | PluginLifecycleState::PendingEnable ), pending_enable_state: match d.registry.lifecycle_state { PluginLifecycleState::PendingEnable => Some(true), PluginLifecycleState::PendingDisable => Some(false), _ => None, }, pending_uninstall: d.registry.lifecycle_state == PluginLifecycleState::PendingUninstall, restart_required: d.registry.restart_required, trust_level: d.manifest.trust_level, contribution_summary: PluginContributionSummary::from(&d.manifest.contributes), error: d.registry.error, }) } fn plugin_asset_url( plugin_id: &PluginId, version: &str, hash: &ContentHash, path: &domain::RelativePath, ) -> String { format!( "idea-plugin://{}/{}/{}/{}", plugin_id.as_str(), version, hash.as_str(), path.as_str() ) } async fn descriptor_for( packages: &dyn PluginPackageStore, validator: &dyn PluginManifestValidator, entry: PluginRegistryEntry, ) -> Result { let bytes = packages .read_manifest(&plugin_package_ref(&entry.id)) .await .map_err(map_store)?; let manifest = validator .validate(&bytes.bytes, &plugin_package_ref(&entry.id)) .map_err(map_manifest)?; Ok(PluginDescriptor { manifest, registry: entry, }) } /// Lists admin plugins. pub struct ListPlugins { packages: Arc, registry: Arc, validator: Arc, } impl ListPlugins { /// Builds the use case. #[must_use] pub fn new( packages: Arc, registry: Arc, validator: Arc, ) -> Self { Self { packages, registry, validator, } } /// Executes the use case. pub async fn execute(&self) -> Result, AppError> { let registry = self.registry.load_registry().await.map_err(map_registry)?; let mut out = Vec::new(); for entry in registry.plugins { match descriptor_for( self.packages.as_ref(), self.validator.as_ref(), entry.clone(), ) .await { Ok(d) => out.push(admin_from_descriptor(d, self.packages.as_ref())?), Err(err) => { let invalid = PluginRegistryEntry { lifecycle_state: PluginLifecycleState::Invalid, error: Some(err.to_string()), ..entry }; let placeholder = PluginManifest { idea_plugin_manifest_version: 1, id: invalid.id.clone(), display_name: invalid.id.as_str().to_owned(), publisher: None, version: domain::PluginVersion::new("0.0.0") .expect("literal semver is valid"), description: None, engine_idea: None, main: domain::RelativePath::new("dist/index.js") .expect("literal path is valid"), icon: None, trust_level: PluginTrustLevel::Full, capabilities: Vec::new(), contributes: PluginContributionSet::default(), }; out.push(admin_from_descriptor( PluginDescriptor { manifest: placeholder, registry: invalid, }, self.packages.as_ref(), )?); } } } out.sort_by(|a, b| a.display_name.cmp(&b.display_name).then(a.id.cmp(&b.id))); Ok(out) } } /// Reviews a plugin package without committing it. pub struct ReviewPluginPackage { packages: Arc, validator: Arc, } impl ReviewPluginPackage { /// Builds the use case. #[must_use] pub fn new( packages: Arc, validator: Arc, ) -> Self { Self { packages, validator, } } /// Executes the use case. pub async fn execute(&self, input: ReviewPluginPackageInput) -> Result { let staged = match input { ReviewPluginPackageInput::Archive { path } => self .packages .install_from_archive(&LocalPath::new(path)) .await .map_err(map_store)?, ReviewPluginPackageInput::Directory { path } => self .packages .install_from_directory(&LocalPath::new(path)) .await .map_err(map_store)?, }; review_staged(self.packages.as_ref(), self.validator.as_ref(), &staged).await } } async fn review_staged( packages: &dyn PluginPackageStore, validator: &dyn PluginManifestValidator, staged: &StagedPluginPackage, ) -> Result { let package = domain::PluginPackageRef { plugin_id: None, root: staged.root.clone(), }; let PluginManifestBytes { bytes } = packages.read_manifest(&package).await.map_err(map_store)?; let manifest = validator.validate(&bytes, &package).map_err(map_manifest)?; Ok(PluginReview { contribution_summary: PluginContributionSummary::from(&manifest.contributes), trust_level: manifest.trust_level, manifest, source: staged.source.clone(), content_hash: staged.content_hash.as_str().to_owned(), }) } /// Installs from archive. pub struct InstallPluginFromArchive { packages: Arc, registry: Arc, validator: Arc, events: Arc, mcp: Arc, } impl InstallPluginFromArchive { /// Builds the use case. #[must_use] pub fn new( packages: Arc, registry: Arc, validator: Arc, events: Arc, mcp: Arc, ) -> Self { Self { packages, registry, validator, events, mcp, } } /// Executes the use case. pub async fn execute(&self, path: String) -> Result { install_from_staged( self.packages.as_ref(), self.registry.as_ref(), self.validator.as_ref(), self.events.as_ref(), self.mcp.as_ref(), self.packages .install_from_archive(&LocalPath::new(path)) .await .map_err(map_store)?, ) .await } } /// Installs from directory. pub struct InstallPluginFromDirectory { packages: Arc, registry: Arc, validator: Arc, events: Arc, mcp: Arc, } impl InstallPluginFromDirectory { /// Builds the use case. #[must_use] pub fn new( packages: Arc, registry: Arc, validator: Arc, events: Arc, mcp: Arc, ) -> Self { Self { packages, registry, validator, events, mcp, } } /// Executes the use case. pub async fn execute(&self, path: String) -> Result { install_from_staged( self.packages.as_ref(), self.registry.as_ref(), self.validator.as_ref(), self.events.as_ref(), self.mcp.as_ref(), self.packages .install_from_directory(&LocalPath::new(path)) .await .map_err(map_store)?, ) .await } } async fn install_from_staged( packages: &dyn PluginPackageStore, registry_store: &dyn PluginRegistryStore, validator: &dyn PluginManifestValidator, events: &dyn EventBus, mcp: &dyn PluginMcpSupervisor, staged: StagedPluginPackage, ) -> Result { let review = review_staged(packages, validator, &staged).await?; let plugin_id = review.manifest.id.clone(); packages .commit_install(staged, &plugin_id) .await .map_err(map_store)?; let mut registry = registry_store.load_registry().await.map_err(map_registry)?; let entry = PluginRegistryEntry { id: plugin_id.clone(), lifecycle_state: PluginLifecycleState::Enabled, source: review.source.clone(), content_hash: ContentHash::new(review.content_hash.clone()) .map_err(|e| AppError::Invalid(e.to_string()))?, restart_required: true, error: None, }; registry.upsert(entry.clone()); registry_store .save_registry(®istry) .await .map_err(map_registry)?; events.publish(DomainEvent::PluginInstalled { plugin_id: plugin_id.clone(), version: review.manifest.version.clone(), }); let _ = mcp .reconcile(active_mcp_specs(packages, validator, ®istry).await?) .await; let admin = admin_from_descriptor( PluginDescriptor { manifest: review.manifest.clone(), registry: entry, }, packages, )?; Ok(PluginInstallResult { plugin: admin, review, restart_required: true, }) } /// Enable/disable input. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SetPluginEnabledInput { /// Plugin id. pub plugin_id: String, /// Desired enabled state. pub enabled: bool, } /// Enables or disables a plugin. pub struct SetPluginEnabled { packages: Arc, registry: Arc, validator: Arc, events: Arc, mcp: Arc, } impl SetPluginEnabled { /// Builds the use case. #[must_use] pub fn new( packages: Arc, registry: Arc, validator: Arc, events: Arc, mcp: Arc, ) -> Self { Self { packages, registry, validator, events, mcp, } } /// Executes the use case. pub async fn execute(&self, input: SetPluginEnabledInput) -> Result { let plugin_id = PluginId::new(input.plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?; let mut registry = self.registry.load_registry().await.map_err(map_registry)?; let entry = registry .plugins .iter_mut() .find(|p| p.id == plugin_id) .ok_or_else(|| AppError::NotFound("plugin".to_owned()))?; entry.lifecycle_state = if input.enabled { PluginLifecycleState::Enabled } else { PluginLifecycleState::Disabled }; entry.restart_required = true; let saved = entry.clone(); self.registry .save_registry(®istry) .await .map_err(map_registry)?; if input.enabled { self.events.publish(DomainEvent::PluginEnabled { plugin_id: plugin_id.clone(), }); } else { self.mcp.stop_plugin(&plugin_id).await.map_err(map_mcp)?; self.events.publish(DomainEvent::PluginDisabled { plugin_id: plugin_id.clone(), restart_required: true, }); } let _ = self .mcp .reconcile( active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), ®istry) .await?, ) .await; let descriptor = descriptor_for(self.packages.as_ref(), self.validator.as_ref(), saved).await?; admin_from_descriptor(descriptor, self.packages.as_ref()) } } /// Uninstall input. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UninstallPluginInput { /// Plugin id. pub plugin_id: String, } /// Uninstalls a plugin. pub struct UninstallPlugin { packages: Arc, registry: Arc, events: Arc, mcp: Arc, } impl UninstallPlugin { /// Builds the use case. #[must_use] pub fn new( packages: Arc, registry: Arc, events: Arc, mcp: Arc, ) -> Self { Self { packages, registry, events, mcp, } } /// Executes the use case. pub async fn execute( &self, input: UninstallPluginInput, ) -> Result { let plugin_id = PluginId::new(input.plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?; self.mcp.stop_plugin(&plugin_id).await.map_err(map_mcp)?; let mut registry = self.registry.load_registry().await.map_err(map_registry)?; registry .remove(&plugin_id) .ok_or_else(|| AppError::NotFound("plugin".to_owned()))?; self.registry .save_registry(®istry) .await .map_err(map_registry)?; let removal = self .packages .remove_package(&plugin_id) .await .map_err(map_store)?; self.events.publish(DomainEvent::PluginUninstalled { plugin_id: plugin_id.clone(), restart_required: true, }); Ok(UninstallPluginResult { plugin_id: plugin_id.as_str().to_owned(), removal_outcome: removal, restart_required: true, }) } } /// Lists runtime contributions. pub struct ListPluginRuntimeContributions { packages: Arc, registry: Arc, validator: Arc, } impl ListPluginRuntimeContributions { /// Builds the use case. #[must_use] pub fn new( packages: Arc, registry: Arc, validator: Arc, ) -> Self { Self { packages, registry, validator, } } /// Executes the use case. pub async fn execute(&self) -> Result { let registry = self.registry.load_registry().await.map_err(map_registry)?; let mut plugins = Vec::new(); for entry in registry.plugins { if !entry.lifecycle_state.is_runtime_active() { continue; } let descriptor = descriptor_for(self.packages.as_ref(), self.validator.as_ref(), entry).await?; let bundle = plugin_asset_url( &descriptor.manifest.id, descriptor.manifest.version.as_str(), &descriptor.registry.content_hash, &descriptor.manifest.main, ); let icon_url = match &descriptor.manifest.icon { Some(icon) => Some(plugin_asset_url( &descriptor.manifest.id, descriptor.manifest.version.as_str(), &descriptor.registry.content_hash, icon, )), None => None, }; plugins.push(PluginRuntimePlugin { id: descriptor.manifest.id.as_str().to_owned(), display_name: descriptor.manifest.display_name, publisher: descriptor.manifest.publisher, version: descriptor.manifest.version.as_str().to_owned(), bundle_url: bundle, icon_url, content_hash: descriptor.registry.content_hash.as_str().to_owned(), contributes: descriptor.manifest.contributes, }); } Ok(PluginRuntimeCatalog { plugins }) } } /// Reconciles plugin MCP servers. pub struct ReconcilePluginMcpServers { packages: Arc, registry: Arc, validator: Arc, mcp: Arc, } impl ReconcilePluginMcpServers { /// Builds the use case. #[must_use] pub fn new( packages: Arc, registry: Arc, validator: Arc, mcp: Arc, ) -> Self { Self { packages, registry, validator, mcp, } } /// Executes the use case. pub async fn execute(&self) -> Result { let registry = self.registry.load_registry().await.map_err(map_registry)?; let specs = active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), ®istry).await?; self.mcp.reconcile(specs).await.map_err(map_mcp) } } async fn active_mcp_specs( packages: &dyn PluginPackageStore, validator: &dyn PluginManifestValidator, registry: &domain::PluginRegistry, ) -> Result, AppError> { let installed_roots = packages .list_installed() .await .map_err(map_store)? .into_iter() .filter_map(|p| p.plugin_id.clone().map(|id| (id, p.root))) .collect::>(); let app_data_dir = packages.app_data_dir_label(); let mut specs = Vec::new(); for entry in ®istry.plugins { if !entry.lifecycle_state.is_runtime_active() { continue; } let descriptor = descriptor_for(packages, validator, entry.clone()).await?; let plugin_root = installed_roots .get(&descriptor.manifest.id) .cloned() .unwrap_or_else(|| plugin_package_ref(&descriptor.manifest.id).root); for server in descriptor.manifest.contributes.mcp_servers { if !server.auto_start { continue; } let command = substitute_vars(&server.command, &plugin_root, app_data_dir.as_deref()); let command = if server.allow_absolute_command || looks_absolute(&command) { command } else { format!("{}/{}", plugin_root.trim_end_matches(['/', '\\']), command) }; specs.push(PluginMcpServerSpec { identity: format!( "plugin:{}:{}", descriptor.manifest.id.as_str(), server.id.as_str() ), plugin_id: descriptor.manifest.id.clone(), server_id: server.id, display_name: server.display_name, command, args: server .args .into_iter() .map(|a| substitute_vars(&a, &plugin_root, app_data_dir.as_deref())) .collect(), env: server .env .into_iter() .map(|(k, v)| { ( k, substitute_vars(&v, &plugin_root, app_data_dir.as_deref()), ) }) .collect(), cwd: substitute_vars( server.cwd.as_deref().unwrap_or("${pluginRoot}"), &plugin_root, app_data_dir.as_deref(), ), transport: server.transport, }); } } Ok(specs) } fn substitute_vars(raw: &str, plugin_root: &str, app_data_dir: Option<&str>) -> String { let value = raw.replace("${pluginRoot}", plugin_root); match app_data_dir { Some(app_data_dir) => value.replace("${appDataDir}", app_data_dir), None => value, } } /// JSON manifest validator. #[derive(Debug, Clone)] pub struct JsonPluginManifestValidator { idea_version: String, } impl JsonPluginManifestValidator { /// Builds a validator for the current app version. #[must_use] pub fn new(idea_version: impl Into) -> Self { Self { idea_version: idea_version.into(), } } } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawManifest { idea_plugin_manifest_version: u32, id: String, display_name: String, #[serde(default)] publisher: Option, version: String, #[serde(default)] description: Option, #[serde(default)] engines: RawEngines, main: String, #[serde(default)] icon: Option, trust_level: String, #[serde(default)] capabilities: Vec, contributes: RawContributes, } #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct RawEngines { #[serde(default)] idea: Option, } #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct RawContributes { #[serde(default)] menus: Vec, #[serde(default)] menu_items: Vec, #[serde(default)] layouts: Vec, #[serde(default)] mcp_servers: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawMenu { id: String, label: String, top_level: bool, #[serde(default)] order: Option, #[serde(default)] icon: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawMenuItem { id: String, target_menu_id: String, label: String, command: String, #[serde(default)] order: Option, #[serde(default)] icon: Option, #[serde(default)] when: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawLayout { #[serde(rename = "type")] layout_type: String, label: String, component: String, #[serde(default)] order: Option, #[serde(default)] icon: Option, #[serde(default)] when: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawMcpServer { id: String, display_name: String, command: String, #[serde(default)] args: Vec, #[serde(default)] env: std::collections::BTreeMap, #[serde(default)] cwd: Option, transport: String, #[serde(default)] auto_start: bool, #[serde(default)] allow_absolute_command: bool, } impl PluginManifestValidator for JsonPluginManifestValidator { fn validate( &self, bytes: &[u8], _package_root: &domain::PluginPackageRef, ) -> Result { let raw: RawManifest = serde_json::from_slice(bytes).map_err(|e| PluginManifestError::Json(e.to_string()))?; if raw.idea_plugin_manifest_version != 1 { return Err(PluginManifestError::Invalid( "ideaPluginManifestVersion must be 1".to_owned(), )); } if raw.display_name.trim().is_empty() { return Err(PluginManifestError::Invalid( "displayName is required".to_owned(), )); } if raw.trust_level != "full" { return Err(PluginManifestError::Invalid( "trustLevel must be full in v1".to_owned(), )); } if let Some(range) = &raw.engines.idea { if !engine_allows(range, &self.idea_version) { return Err(PluginManifestError::IncompatibleEngine(range.clone())); } } let id = PluginId::new(raw.id).map_err(|e| PluginManifestError::Invalid(e.to_string()))?; let version = domain::PluginVersion::new(raw.version) .map_err(|e| PluginManifestError::Invalid(e.to_string()))?; let main = domain::RelativePath::new(raw.main) .map_err(|e| PluginManifestError::Invalid(e.to_string()))?; if !(main.as_str().ends_with(".js") || main.as_str().ends_with(".mjs")) { return Err(PluginManifestError::Invalid( "main must point to a .js or .mjs file".to_owned(), )); } let icon = raw .icon .map(domain::RelativePath::new) .transpose() .map_err(|e| PluginManifestError::Invalid(e.to_string()))?; let capabilities = raw .capabilities .into_iter() .map(|c| match c.as_str() { "ui" => Ok(domain::PluginCapability::Ui), "mcp" => Ok(domain::PluginCapability::Mcp), _ => Err(PluginManifestError::Invalid(format!( "unknown capability: {c}" ))), }) .collect::, _>>()?; let contributes = validate_contributes(raw.contributes)?; Ok(PluginManifest { idea_plugin_manifest_version: 1, id, display_name: raw.display_name, publisher: raw.publisher, version, description: raw.description, engine_idea: raw.engines.idea, main, icon, trust_level: PluginTrustLevel::Full, capabilities, contributes, }) } } fn validate_contributes(raw: RawContributes) -> Result { let mut seen = HashSet::new(); let mut insert = |id: &str| { if !seen.insert(id.to_owned()) { Err(PluginManifestError::Invalid(format!( "duplicate contribution id: {id}" ))) } else { Ok(()) } }; let menus = raw .menus .into_iter() .map(|m| { insert(&m.id)?; if !m.top_level { return Err(PluginManifestError::Invalid( "menus[].topLevel must be true".to_owned(), )); } Ok(domain::PluginTopLevelMenuContribution { id: m.id, label: m.label, top_level: true, order: m.order, icon: m .icon .map(domain::RelativePath::new) .transpose() .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, }) }) .collect::, _>>()?; let menu_items = raw .menu_items .into_iter() .map(|m| { insert(&m.id)?; Ok(domain::PluginMenuItemContribution { id: m.id, target_menu_id: m.target_menu_id, label: m.label, command: domain::PluginCommandId::new(m.command) .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, order: m.order, icon: m .icon .map(domain::RelativePath::new) .transpose() .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, when: m.when, }) }) .collect::, _>>()?; let layouts = raw .layouts .into_iter() .map(|l| { insert(&l.layout_type)?; if l.component.trim().is_empty() { return Err(PluginManifestError::Invalid( "layouts[].component is required".to_owned(), )); } Ok(domain::PluginLayoutContribution { layout_type: domain::PluginLayoutType::new(l.layout_type) .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, label: l.label, component: l.component, order: l.order, icon: l .icon .map(domain::RelativePath::new) .transpose() .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, when: l.when, }) }) .collect::, _>>()?; let mcp_servers = raw .mcp_servers .into_iter() .map(|s| { insert(&s.id)?; if s.transport != "stdio" { return Err(PluginManifestError::Invalid( "mcpServers[].transport must be stdio".to_owned(), )); } if !s.allow_absolute_command && looks_absolute(&s.command) { return Err(PluginManifestError::Invalid( "absolute MCP command requires allowAbsoluteCommand=true".to_owned(), )); } if !looks_absolute(&s.command) { domain::RelativePath::new(s.command.clone()) .map_err(|e| PluginManifestError::Invalid(e.to_string()))?; } if let Some(cwd) = &s.cwd { if cwd != "${pluginRoot}" && !cwd.contains("${appDataDir}") && !cwd.contains("${pluginRoot}") { domain::RelativePath::new(cwd.clone()) .map_err(|e| PluginManifestError::Invalid(e.to_string()))?; } } Ok(domain::PluginMcpServerContribution { id: domain::PluginMcpServerId::new(s.id) .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, display_name: s.display_name, command: s.command, args: s.args, env: s.env.into_iter().collect(), cwd: s.cwd, transport: s.transport, auto_start: s.auto_start, allow_absolute_command: s.allow_absolute_command, }) }) .collect::, _>>()?; Ok(PluginContributionSet { menus, menu_items, layouts, mcp_servers, }) } fn looks_absolute(path: &str) -> bool { path.starts_with('/') || path.starts_with('\\') || path.as_bytes().get(1) == Some(&b':') } fn engine_allows(range: &str, current: &str) -> bool { let cur = parse_version_tuple(current).unwrap_or((0, 0, 0)); range.split_whitespace().all(|part| { if let Some(v) = part.strip_prefix(">=") { parse_version_tuple(v).is_some_and(|min| cur >= min) } else if let Some(v) = part.strip_prefix('>') { parse_version_tuple(v).is_some_and(|min| cur > min) } else if let Some(v) = part.strip_prefix("<=") { parse_version_tuple(v).is_some_and(|max| cur <= max) } else if let Some(v) = part.strip_prefix('<') { parse_version_tuple(v).is_some_and(|max| cur < max) } else if let Some(v) = part.strip_prefix('=') { parse_version_tuple(v).is_some_and(|eq| cur == eq) } else { true } }) } fn parse_version_tuple(raw: &str) -> Option<(u64, u64, u64)> { let core = raw.split_once('-').map_or(raw, |(a, _)| a); let mut parts = core.split('.'); Some(( parts.next()?.parse().ok()?, parts.next()?.parse().ok()?, parts.next()?.parse().ok()?, )) } #[cfg(test)] mod tests { use super::*; use domain::ports::{EventStream, PluginPackageStore, PluginRegistryStore, PluginStoreError}; use std::collections::HashMap; use std::sync::Mutex; fn validator() -> JsonPluginManifestValidator { JsonPluginManifestValidator::new("0.3.0") } fn valid_manifest() -> Vec { br#"{ "ideaPluginManifestVersion": 1, "id": "dev.acme.gitgraph", "displayName": "Git Graph", "publisher": "Acme", "version": "1.2.3", "engines": {"idea": ">=0.1.0 <1.0.0"}, "main": "dist/index.js", "trustLevel": "full", "capabilities": ["ui", "mcp"], "contributes": { "menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}], "menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}], "layouts": [{"type":"dev.acme.layout","label":"Graph","component":"Graph"}], "mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true}] } }"#.to_vec() } fn plugin_id() -> PluginId { PluginId::new("dev.acme.gitgraph").unwrap() } fn content_hash(raw: &str) -> ContentHash { ContentHash::new(raw).unwrap() } struct FakePackages { manifests: Mutex>>, staged: Mutex>, removed: Mutex>, } impl FakePackages { fn with_manifest(bytes: Vec) -> Self { let mut manifests = HashMap::new(); manifests.insert("dev.acme.gitgraph".to_owned(), bytes); Self { manifests: Mutex::new(manifests), staged: Mutex::new(Some(StagedPluginPackage { root: "/stage/plugin".to_owned(), source: PluginInstallSource::Directory { path_label: "/source/plugin".to_owned(), }, content_hash: content_hash("abc123"), })), removed: Mutex::new(Vec::new()), } } } #[async_trait::async_trait] impl PluginPackageStore for FakePackages { async fn list_installed(&self) -> Result, PluginStoreError> { Ok(self .manifests .lock() .unwrap() .keys() .map(|id| domain::PluginPackageRef { plugin_id: Some(PluginId::new(id.clone()).unwrap()), root: format!("/installed/{id}"), }) .collect()) } async fn read_manifest( &self, package: &domain::PluginPackageRef, ) -> Result { let key = package .plugin_id .as_ref() .map_or("dev.acme.gitgraph", PluginId::as_str); self.manifests .lock() .unwrap() .get(key) .cloned() .map(|bytes| PluginManifestBytes { bytes }) .ok_or(PluginStoreError::NotFound) } async fn install_from_archive( &self, _archive: &LocalPath, ) -> Result { self.staged .lock() .unwrap() .take() .ok_or_else(|| PluginStoreError::Invalid("missing staged package".to_owned())) } async fn install_from_directory( &self, _dir: &LocalPath, ) -> Result { self.staged .lock() .unwrap() .take() .ok_or_else(|| PluginStoreError::Invalid("missing staged package".to_owned())) } async fn commit_install( &self, staged: StagedPluginPackage, plugin_id: &PluginId, ) -> Result { self.manifests .lock() .unwrap() .insert(plugin_id.as_str().to_owned(), valid_manifest()); Ok(domain::PluginPackageRef { plugin_id: Some(plugin_id.clone()), root: staged.root, }) } async fn remove_package( &self, plugin_id: &PluginId, ) -> Result { self.removed .lock() .unwrap() .push(plugin_id.as_str().to_owned()); Ok(RemovalOutcome::Removed) } fn bundle_url( &self, plugin_id: &PluginId, entry: &domain::RelativePath, hash: &ContentHash, ) -> Result { Ok(domain::PluginBundleUrl::new(format!( "idea-plugin://{}/current/{}/{}", plugin_id.as_str(), hash.as_str(), entry.as_str() ))) } fn app_data_dir_label(&self) -> Option { Some("/app-data".to_owned()) } } #[derive(Default)] struct FakeRegistry { registry: Mutex, } #[async_trait::async_trait] impl PluginRegistryStore for FakeRegistry { async fn load_registry(&self) -> Result { Ok(self.registry.lock().unwrap().clone()) } async fn save_registry( &self, registry: &domain::PluginRegistry, ) -> Result<(), PluginRegistryError> { *self.registry.lock().unwrap() = registry.clone(); Ok(()) } } #[derive(Default)] struct FakeEvents { events: Mutex>, } impl EventBus for FakeEvents { fn publish(&self, event: DomainEvent) { self.events.lock().unwrap().push(event); } fn subscribe(&self) -> EventStream { Box::new(std::iter::empty()) } } #[derive(Default)] struct FakeMcp { reconciles: Mutex>>, stops: Mutex>, } #[async_trait::async_trait] impl PluginMcpSupervisor for FakeMcp { async fn reconcile( &self, active_servers: Vec, ) -> Result { self.reconciles.lock().unwrap().push(active_servers.clone()); Ok(domain::PluginMcpStatusSet { servers: active_servers .into_iter() .map(|spec| domain::PluginMcpStatus { identity: spec.identity, running: true, error: None, }) .collect(), }) } async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError> { self.stops .lock() .unwrap() .push(plugin_id.as_str().to_owned()); Ok(()) } } fn registry_with(state: PluginLifecycleState) -> domain::PluginRegistry { domain::PluginRegistry { version: 1, plugins: vec![PluginRegistryEntry { id: plugin_id(), lifecycle_state: state, source: PluginInstallSource::Directory { path_label: "/source/plugin".to_owned(), }, content_hash: content_hash("abc123"), restart_required: false, error: None, }], } } #[test] fn validates_manifest_v1() { let m = validator() .validate( &valid_manifest(), &domain::PluginPackageRef { plugin_id: None, root: "x".into(), }, ) .unwrap(); assert_eq!(m.id.as_str(), "dev.acme.gitgraph"); assert_eq!(m.contributes.layouts.len(), 1); assert_eq!(m.contributes.mcp_servers.len(), 1); } #[test] fn rejects_unsafe_main_path_and_non_full_trust() { let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); value["main"] = serde_json::json!("../dist/index.js"); assert!(validator() .validate( &serde_json::to_vec(&value).unwrap(), &domain::PluginPackageRef { plugin_id: None, root: "x".into() } ) .is_err()); value["main"] = serde_json::json!("dist/index.js"); value["trustLevel"] = serde_json::json!("sandbox"); assert!(validator() .validate( &serde_json::to_vec(&value).unwrap(), &domain::PluginPackageRef { plugin_id: None, root: "x".into() } ) .is_err()); } #[test] fn rejects_duplicate_contribution_ids() { let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); value["contributes"]["layouts"][0]["type"] = serde_json::json!("dev.acme.open"); assert!(validator() .validate( &serde_json::to_vec(&value).unwrap(), &domain::PluginPackageRef { plugin_id: None, root: "x".into() } ) .is_err()); } #[tokio::test] async fn runtime_catalog_excludes_disabled_and_pending_uninstall_plugins() { for state in [ PluginLifecycleState::Disabled, PluginLifecycleState::PendingUninstall, PluginLifecycleState::Invalid, ] { let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); let registry = Arc::new(FakeRegistry { registry: Mutex::new(registry_with(state)), }); let usecase = ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator())); let catalog = usecase.execute().await.unwrap(); assert!(catalog.plugins.is_empty(), "{state:?} must not be active"); } } #[tokio::test] async fn reconcile_mcp_uses_only_enabled_auto_start_servers_with_plugin_identity() { let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); let registry = Arc::new(FakeRegistry { registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), }); let mcp = Arc::new(FakeMcp::default()); let usecase = ReconcilePluginMcpServers::new(packages, registry, Arc::new(validator()), mcp.clone()); let statuses = usecase.execute().await.unwrap(); assert_eq!(statuses.servers.len(), 1); assert_eq!( statuses.servers[0].identity, "plugin:dev.acme.gitgraph:dev.acme.mcp" ); let reconciles = mcp.reconciles.lock().unwrap(); assert_eq!(reconciles.len(), 1); assert_eq!( reconciles[0][0].command, "/installed/dev.acme.gitgraph/servers/tool" ); assert_eq!(reconciles[0][0].cwd, "/installed/dev.acme.gitgraph"); } #[tokio::test] async fn reconcile_mcp_does_not_spawn_pending_uninstall_plugin_servers() { let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); let registry = Arc::new(FakeRegistry { registry: Mutex::new(registry_with(PluginLifecycleState::PendingUninstall)), }); let mcp = Arc::new(FakeMcp::default()); let usecase = ReconcilePluginMcpServers::new(packages, registry, Arc::new(validator()), mcp.clone()); let statuses = usecase.execute().await.unwrap(); assert!(statuses.servers.is_empty()); let reconciles = mcp.reconciles.lock().unwrap(); assert_eq!(reconciles.len(), 1); assert!(reconciles[0].is_empty()); } #[tokio::test] async fn reconcile_mcp_substitutes_app_data_dir_in_plugin_server_specs() { let mut manifest: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); manifest["contributes"]["mcpServers"][0]["command"] = serde_json::json!("${appDataDir}/plugin-tools/gitgraph"); manifest["contributes"]["mcpServers"][0]["args"] = serde_json::json!(["--cache", "${appDataDir}/cache", "--root", "${pluginRoot}"]); manifest["contributes"]["mcpServers"][0]["env"] = serde_json::json!({ "PLUGIN_CACHE": "${appDataDir}/cache/dev.acme.gitgraph", "PLUGIN_ROOT": "${pluginRoot}" }); manifest["contributes"]["mcpServers"][0]["cwd"] = serde_json::json!("${appDataDir}/work/dev.acme.gitgraph"); let packages = Arc::new(FakePackages::with_manifest( serde_json::to_vec(&manifest).unwrap(), )); let registry = Arc::new(FakeRegistry { registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), }); let mcp = Arc::new(FakeMcp::default()); let usecase = ReconcilePluginMcpServers::new(packages, registry, Arc::new(validator()), mcp.clone()); usecase.execute().await.unwrap(); let reconciles = mcp.reconciles.lock().unwrap(); let spec = &reconciles[0][0]; assert_eq!(spec.command, "/app-data/plugin-tools/gitgraph"); assert_eq!( spec.args, vec![ "--cache".to_owned(), "/app-data/cache".to_owned(), "--root".to_owned(), "/installed/dev.acme.gitgraph".to_owned() ] ); assert!(spec.env.contains(&( "PLUGIN_CACHE".to_owned(), "/app-data/cache/dev.acme.gitgraph".to_owned() ))); assert!(spec.env.contains(&( "PLUGIN_ROOT".to_owned(), "/installed/dev.acme.gitgraph".to_owned() ))); assert_eq!(spec.cwd, "/app-data/work/dev.acme.gitgraph"); } #[tokio::test] async fn disable_stops_plugin_and_removes_it_from_runtime_catalog() { let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); let registry = Arc::new(FakeRegistry { registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), }); let events = Arc::new(FakeEvents::default()); let mcp = Arc::new(FakeMcp::default()); let disable = SetPluginEnabled::new( packages.clone(), registry.clone(), Arc::new(validator()), events.clone(), mcp.clone(), ); let admin = disable .execute(SetPluginEnabledInput { plugin_id: "dev.acme.gitgraph".to_owned(), enabled: false, }) .await .unwrap(); assert!(!admin.enabled); assert_eq!(admin.lifecycle_state, PluginLifecycleState::Disabled); assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]); assert!(events.events.lock().unwrap().iter().any(|event| matches!( event, DomainEvent::PluginDisabled { restart_required: true, .. } ))); let runtime = ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator())) .execute() .await .unwrap(); assert!(runtime.plugins.is_empty()); } #[tokio::test] async fn uninstall_removes_registry_package_and_stops_mcp() { let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); let registry = Arc::new(FakeRegistry { registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), }); let events = Arc::new(FakeEvents::default()); let mcp = Arc::new(FakeMcp::default()); let uninstall = UninstallPlugin::new( packages.clone(), registry.clone(), events.clone(), mcp.clone(), ); let result = uninstall .execute(UninstallPluginInput { plugin_id: "dev.acme.gitgraph".to_owned(), }) .await .unwrap(); assert_eq!(result.removal_outcome, RemovalOutcome::Removed); assert!(result.restart_required); assert!(registry.load_registry().await.unwrap().plugins.is_empty()); assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]); assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]); assert!(events.events.lock().unwrap().iter().any(|event| matches!( event, DomainEvent::PluginUninstalled { restart_required: true, .. } ))); } }