Lots B1-B4 : modèle de domaine des plugins (manifeste, capacités menus/layouts/MCP), port et registre applicatif, chargement/validation en infrastructure, exposition DTO et commandes Tauri. Tests cargo verts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1715 lines
55 KiB
Rust
1715 lines
55 KiB
Rust
//! 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<String>,
|
|
/// Version.
|
|
pub version: String,
|
|
/// Description.
|
|
pub description: Option<String>,
|
|
/// Icon URL.
|
|
pub icon_url: Option<String>,
|
|
/// Source kind.
|
|
pub source_kind: String,
|
|
/// Source label.
|
|
pub source_label: Option<String>,
|
|
/// Lifecycle state.
|
|
pub lifecycle_state: PluginLifecycleState,
|
|
/// Enabled projection.
|
|
pub enabled: bool,
|
|
/// Pending enable state.
|
|
pub pending_enable_state: Option<bool>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<PluginRuntimePlugin>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// Version.
|
|
pub version: String,
|
|
/// Bundle URL.
|
|
pub bundle_url: String,
|
|
/// Icon URL.
|
|
pub icon_url: Option<String>,
|
|
/// 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<PluginAdmin, AppError> {
|
|
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<PluginDescriptor, AppError> {
|
|
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<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
}
|
|
|
|
impl ListPlugins {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
) -> Self {
|
|
Self {
|
|
packages,
|
|
registry,
|
|
validator,
|
|
}
|
|
}
|
|
|
|
/// Executes the use case.
|
|
pub async fn execute(&self) -> Result<Vec<PluginAdmin>, 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<dyn PluginPackageStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
}
|
|
|
|
impl ReviewPluginPackage {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
) -> Self {
|
|
Self {
|
|
packages,
|
|
validator,
|
|
}
|
|
}
|
|
|
|
/// Executes the use case.
|
|
pub async fn execute(&self, input: ReviewPluginPackageInput) -> Result<PluginReview, AppError> {
|
|
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<PluginReview, AppError> {
|
|
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<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
events: Arc<dyn EventBus>,
|
|
mcp: Arc<dyn PluginMcpSupervisor>,
|
|
}
|
|
|
|
impl InstallPluginFromArchive {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
events: Arc<dyn EventBus>,
|
|
mcp: Arc<dyn PluginMcpSupervisor>,
|
|
) -> Self {
|
|
Self {
|
|
packages,
|
|
registry,
|
|
validator,
|
|
events,
|
|
mcp,
|
|
}
|
|
}
|
|
|
|
/// Executes the use case.
|
|
pub async fn execute(&self, path: String) -> Result<PluginInstallResult, AppError> {
|
|
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<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
events: Arc<dyn EventBus>,
|
|
mcp: Arc<dyn PluginMcpSupervisor>,
|
|
}
|
|
|
|
impl InstallPluginFromDirectory {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
events: Arc<dyn EventBus>,
|
|
mcp: Arc<dyn PluginMcpSupervisor>,
|
|
) -> Self {
|
|
Self {
|
|
packages,
|
|
registry,
|
|
validator,
|
|
events,
|
|
mcp,
|
|
}
|
|
}
|
|
|
|
/// Executes the use case.
|
|
pub async fn execute(&self, path: String) -> Result<PluginInstallResult, AppError> {
|
|
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<PluginInstallResult, AppError> {
|
|
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<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
events: Arc<dyn EventBus>,
|
|
mcp: Arc<dyn PluginMcpSupervisor>,
|
|
}
|
|
|
|
impl SetPluginEnabled {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
events: Arc<dyn EventBus>,
|
|
mcp: Arc<dyn PluginMcpSupervisor>,
|
|
) -> Self {
|
|
Self {
|
|
packages,
|
|
registry,
|
|
validator,
|
|
events,
|
|
mcp,
|
|
}
|
|
}
|
|
|
|
/// Executes the use case.
|
|
pub async fn execute(&self, input: SetPluginEnabledInput) -> Result<PluginAdmin, AppError> {
|
|
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<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
events: Arc<dyn EventBus>,
|
|
mcp: Arc<dyn PluginMcpSupervisor>,
|
|
}
|
|
|
|
impl UninstallPlugin {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
events: Arc<dyn EventBus>,
|
|
mcp: Arc<dyn PluginMcpSupervisor>,
|
|
) -> Self {
|
|
Self {
|
|
packages,
|
|
registry,
|
|
events,
|
|
mcp,
|
|
}
|
|
}
|
|
|
|
/// Executes the use case.
|
|
pub async fn execute(
|
|
&self,
|
|
input: UninstallPluginInput,
|
|
) -> Result<UninstallPluginResult, AppError> {
|
|
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<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
}
|
|
|
|
impl ListPluginRuntimeContributions {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
) -> Self {
|
|
Self {
|
|
packages,
|
|
registry,
|
|
validator,
|
|
}
|
|
}
|
|
|
|
/// Executes the use case.
|
|
pub async fn execute(&self) -> Result<PluginRuntimeCatalog, AppError> {
|
|
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<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
mcp: Arc<dyn PluginMcpSupervisor>,
|
|
}
|
|
|
|
impl ReconcilePluginMcpServers {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
mcp: Arc<dyn PluginMcpSupervisor>,
|
|
) -> Self {
|
|
Self {
|
|
packages,
|
|
registry,
|
|
validator,
|
|
mcp,
|
|
}
|
|
}
|
|
|
|
/// Executes the use case.
|
|
pub async fn execute(&self) -> Result<domain::PluginMcpStatusSet, AppError> {
|
|
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<Vec<PluginMcpServerSpec>, 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::<std::collections::HashMap<_, _>>();
|
|
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<String>) -> 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<String>,
|
|
version: String,
|
|
#[serde(default)]
|
|
description: Option<String>,
|
|
#[serde(default)]
|
|
engines: RawEngines,
|
|
main: String,
|
|
#[serde(default)]
|
|
icon: Option<String>,
|
|
trust_level: String,
|
|
#[serde(default)]
|
|
capabilities: Vec<String>,
|
|
contributes: RawContributes,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawEngines {
|
|
#[serde(default)]
|
|
idea: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawContributes {
|
|
#[serde(default)]
|
|
menus: Vec<RawMenu>,
|
|
#[serde(default)]
|
|
menu_items: Vec<RawMenuItem>,
|
|
#[serde(default)]
|
|
layouts: Vec<RawLayout>,
|
|
#[serde(default)]
|
|
mcp_servers: Vec<RawMcpServer>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawMenu {
|
|
id: String,
|
|
label: String,
|
|
top_level: bool,
|
|
#[serde(default)]
|
|
order: Option<i32>,
|
|
#[serde(default)]
|
|
icon: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawMenuItem {
|
|
id: String,
|
|
target_menu_id: String,
|
|
label: String,
|
|
command: String,
|
|
#[serde(default)]
|
|
order: Option<i32>,
|
|
#[serde(default)]
|
|
icon: Option<String>,
|
|
#[serde(default)]
|
|
when: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawLayout {
|
|
#[serde(rename = "type")]
|
|
layout_type: String,
|
|
label: String,
|
|
component: String,
|
|
#[serde(default)]
|
|
order: Option<i32>,
|
|
#[serde(default)]
|
|
icon: Option<String>,
|
|
#[serde(default)]
|
|
when: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawMcpServer {
|
|
id: String,
|
|
display_name: String,
|
|
command: String,
|
|
#[serde(default)]
|
|
args: Vec<String>,
|
|
#[serde(default)]
|
|
env: std::collections::BTreeMap<String, String>,
|
|
#[serde(default)]
|
|
cwd: Option<String>,
|
|
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<PluginManifest, PluginManifestError> {
|
|
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::<Result<Vec<_>, _>>()?;
|
|
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<PluginContributionSet, PluginManifestError> {
|
|
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::<Result<Vec<_>, _>>()?;
|
|
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::<Result<Vec<_>, _>>()?;
|
|
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::<Result<Vec<_>, _>>()?;
|
|
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::<Result<Vec<_>, _>>()?;
|
|
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<u8> {
|
|
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<HashMap<String, Vec<u8>>>,
|
|
staged: Mutex<Option<StagedPluginPackage>>,
|
|
removed: Mutex<Vec<String>>,
|
|
}
|
|
|
|
impl FakePackages {
|
|
fn with_manifest(bytes: Vec<u8>) -> 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<Vec<domain::PluginPackageRef>, 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<PluginManifestBytes, PluginStoreError> {
|
|
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<StagedPluginPackage, PluginStoreError> {
|
|
self.staged
|
|
.lock()
|
|
.unwrap()
|
|
.take()
|
|
.ok_or_else(|| PluginStoreError::Invalid("missing staged package".to_owned()))
|
|
}
|
|
|
|
async fn install_from_directory(
|
|
&self,
|
|
_dir: &LocalPath,
|
|
) -> Result<StagedPluginPackage, PluginStoreError> {
|
|
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<domain::PluginPackageRef, PluginStoreError> {
|
|
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<RemovalOutcome, PluginStoreError> {
|
|
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<domain::PluginBundleUrl, PluginStoreError> {
|
|
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<String> {
|
|
Some("/app-data".to_owned())
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FakeRegistry {
|
|
registry: Mutex<domain::PluginRegistry>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl PluginRegistryStore for FakeRegistry {
|
|
async fn load_registry(&self) -> Result<domain::PluginRegistry, PluginRegistryError> {
|
|
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<Vec<DomainEvent>>,
|
|
}
|
|
|
|
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<Vec<Vec<PluginMcpServerSpec>>>,
|
|
stops: Mutex<Vec<String>>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl PluginMcpSupervisor for FakeMcp {
|
|
async fn reconcile(
|
|
&self,
|
|
active_servers: Vec<PluginMcpServerSpec>,
|
|
) -> Result<domain::PluginMcpStatusSet, PluginMcpError> {
|
|
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,
|
|
..
|
|
}
|
|
)));
|
|
}
|
|
}
|