Skip to main content

vtcode_core/marketplace/
installer.rs

1//! Plugin installer for marketplace system
2
3use std::path::{Component, Path, PathBuf};
4
5use crate::tools::plugins::PluginRuntime;
6use crate::utils::validation::{validate_all_non_empty, validate_non_empty};
7use anyhow::{Context, Result, bail};
8use tokio::fs;
9use vtcode_commons::VtCodePaths;
10use vtcode_commons::fs::{read_private_file_no_follow, write_private_file_atomic, write_private_json_file};
11
12use super::PluginManifest;
13
14/// Plugin installer that handles downloading and installing plugins from marketplaces
15pub struct PluginInstaller {
16    /// Base directory for installed plugins
17    pub plugins_dir: PathBuf,
18
19    /// Reference to the core plugin runtime for integration
20    core_plugin_runtime: Option<PluginRuntime>,
21}
22
23impl PluginInstaller {
24    /// Create a new installer targeting the given plugins directory.
25    pub fn new(plugins_dir: PathBuf, core_plugin_runtime: Option<PluginRuntime>) -> Self {
26        Self { plugins_dir, core_plugin_runtime }
27    }
28
29    /// Install a plugin from its manifest
30    pub async fn install_plugin(&self, manifest: &PluginManifest) -> Result<()> {
31        self.validate_manifest(manifest)?;
32
33        // Create plugins directory if it doesn't exist
34        VtCodePaths::ensure_user_dir(&self.plugins_dir)
35            .with_context(|| format!("Failed to create plugin directory: {}", self.plugins_dir.display()))?;
36
37        // Create plugin installation directory
38        let plugin_dir = self.plugins_dir.join(&manifest.id);
39        VtCodePaths::ensure_user_dir(&plugin_dir)
40            .with_context(|| format!("Failed to create plugin directory: {}", plugin_dir.display()))?;
41
42        // Download the plugin from its source
43        self.download_plugin(manifest, &plugin_dir).await?;
44
45        // Save the manifest to the plugin directory
46        let manifest_dir = plugin_dir.join(".vtcode-plugin");
47        let manifest_path = manifest_dir.join("plugin.json");
48        write_private_json_file(&manifest_path, manifest).await?;
49
50        // Integrate with VT Code's existing plugin system
51        self.integrate_with_core_plugin_system(&manifest_path).await?;
52
53        Ok(())
54    }
55
56    /// Integrate the installed plugin with VT Code's core plugin system
57    async fn integrate_with_core_plugin_system(&self, manifest_path: &Path) -> Result<()> {
58        // This would load the plugin into VT Code's plugin runtime
59        if let Some(runtime) = &self.core_plugin_runtime {
60            // Load the plugin manifest and register it with the core runtime
61            let handle = runtime.register_manifest(manifest_path).await?;
62            tracing::info!(plugin_id = %handle.manifest.id, "registered plugin with core runtime");
63        } else {
64            tracing::info!(path = %manifest_path.display(), "no core plugin runtime, skipping integration");
65        }
66
67        Ok(())
68    }
69
70    /// Download plugin from its source
71    async fn download_plugin(&self, manifest: &PluginManifest, plugin_dir: &Path) -> Result<()> {
72        // Validate the manifest before downloading
73        self.validate_manifest(manifest)?;
74
75        tracing::info!(plugin_id = %manifest.id, source = %manifest.source, "downloading plugin");
76
77        // Determine the source type and download accordingly
78        if manifest.source.starts_with("http") {
79            self.download_from_http(manifest, plugin_dir).await?;
80        } else if manifest.source.starts_with("file://") {
81            self.download_from_file(manifest, plugin_dir).await?;
82        } else if fs::try_exists(&manifest.source)
83            .await
84            .with_context(|| format!("Failed to check plugin source {}", manifest.source))?
85        {
86            // Local path
87            self.download_from_local(manifest, plugin_dir).await?;
88        } else {
89            // Assume it's a git repository
90            self.download_from_git(manifest, plugin_dir).await?;
91        }
92
93        Ok(())
94    }
95
96    /// Download plugin from HTTP source
97    async fn download_from_http(&self, manifest: &PluginManifest, plugin_dir: &Path) -> Result<()> {
98        // For now, we'll create a placeholder since we don't have the actual HTTP client configured
99        let placeholder_path = self.entrypoint_path(plugin_dir, manifest)?;
100
101        write_private_file_atomic(&placeholder_path, format!("# HTTP Downloaded plugin: {}\n", manifest.id)).await?;
102
103        tracing::info!(plugin_id = %manifest.id, "http download completed");
104        Ok(())
105    }
106
107    /// Download plugin from local file
108    async fn download_from_file(&self, manifest: &PluginManifest, plugin_dir: &Path) -> Result<()> {
109        let source_path = PathBuf::from(&manifest.source.replace("file://", ""));
110        let content = read_private_file_no_follow(&source_path)
111            .await
112            .with_context(|| format!("Failed to read local source file {}", source_path.display()))?;
113        let dest_path = self.entrypoint_path(plugin_dir, manifest)?;
114
115        // Copy the file from source to destination
116        write_private_file_atomic(&dest_path, content).await.with_context(|| {
117            format!("Failed to copy plugin from {} to {}", source_path.display(), dest_path.display())
118        })?;
119
120        tracing::info!(plugin_id = %manifest.id, "local file copy completed");
121        Ok(())
122    }
123
124    /// Download plugin from local path
125    async fn download_from_local(&self, manifest: &PluginManifest, plugin_dir: &Path) -> Result<()> {
126        let source_path = PathBuf::from(&manifest.source);
127        let content = read_private_file_no_follow(&source_path)
128            .await
129            .with_context(|| format!("Failed to read local source path {}", source_path.display()))?;
130        let dest_path = self.entrypoint_path(plugin_dir, manifest)?;
131
132        // Copy the file from source to destination
133        write_private_file_atomic(&dest_path, content).await.with_context(|| {
134            format!("Failed to copy plugin from {} to {}", source_path.display(), dest_path.display())
135        })?;
136
137        tracing::info!(plugin_id = %manifest.id, "local path copy completed");
138        Ok(())
139    }
140
141    /// Download plugin from git repository
142    async fn download_from_git(&self, manifest: &PluginManifest, plugin_dir: &Path) -> Result<()> {
143        // For now, we'll create a placeholder since we don't have git functionality integrated
144        let placeholder_path = self.entrypoint_path(plugin_dir, manifest)?;
145
146        write_private_file_atomic(&placeholder_path, format!("# Git downloaded plugin: {}\n", manifest.id)).await?;
147
148        tracing::info!(plugin_id = %manifest.id, "git download completed");
149        Ok(())
150    }
151
152    /// Validate the plugin manifest before installation
153    pub fn validate_manifest(&self, manifest: &PluginManifest) -> Result<()> {
154        // Validate required fields
155        validate_non_empty(&manifest.id, "Plugin ID")?;
156        validate_non_empty(&manifest.name, "Plugin name")?;
157        validate_non_empty(&manifest.source, "Plugin source URL")?;
158
159        // Validate entrypoint path
160        validate_plugin_component(&manifest.id, "Plugin ID")?;
161        validate_relative_path(&manifest.entrypoint, "Plugin entrypoint")?;
162
163        // Validate trust level if specified
164        if let Some(trust_level) = &manifest.trust_level {
165            match trust_level {
166                crate::config::PluginTrustLevel::Sandbox
167                | crate::config::PluginTrustLevel::Trusted
168                | crate::config::PluginTrustLevel::Untrusted => {
169                    // Valid trust level
170                }
171            }
172        }
173
174        // Validate dependencies if any
175        validate_all_non_empty(&manifest.dependencies, "Plugin dependencies")?;
176
177        Ok(())
178    }
179
180    /// Uninstall a plugin by ID
181    pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> {
182        validate_plugin_component(plugin_id, "Plugin ID")?;
183        VtCodePaths::ensure_user_dir(&self.plugins_dir)
184            .with_context(|| format!("Failed to validate plugin directory: {}", self.plugins_dir.display()))?;
185        let plugin_dir = self.plugins_dir.join(plugin_id);
186        let metadata = match fs::symlink_metadata(&plugin_dir).await {
187            Ok(metadata) => metadata,
188            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
189                bail!("Installed plugin path does not exist: {}", plugin_dir.display());
190            }
191            Err(error) => {
192                return Err(error)
193                    .with_context(|| format!("Failed to inspect installed plugin {}", plugin_dir.display()));
194            }
195        };
196        if metadata.file_type().is_symlink() || !metadata.is_dir() {
197            bail!("Refusing to remove non-directory plugin path: {}", plugin_dir.display());
198        }
199
200        // Remove from VT Code's plugin system before filesystem removal
201        self.remove_from_core_plugin_system(plugin_id).await?;
202
203        fs::remove_dir_all(&plugin_dir)
204            .await
205            .with_context(|| format!("Failed to remove plugin directory: {}", plugin_dir.display()))?;
206
207        Ok(())
208    }
209
210    /// Remove plugin from VT Code's core plugin system
211    async fn remove_from_core_plugin_system(&self, plugin_id: &str) -> Result<()> {
212        // Remove the plugin from VT Code's plugin runtime
213        if let Some(runtime) = &self.core_plugin_runtime {
214            // Unload the plugin by ID
215            runtime
216                .unload_plugin(plugin_id)
217                .await
218                .with_context(|| format!("Failed to unload plugin from runtime: {plugin_id}"))?;
219            tracing::info!(plugin_id = %plugin_id, "unloaded plugin from core runtime");
220        } else {
221            tracing::info!(plugin_id = %plugin_id, "no core plugin runtime, skipping removal");
222        }
223
224        Ok(())
225    }
226
227    /// Check if a plugin is installed
228    pub async fn is_installed(&self, plugin_id: &str) -> bool {
229        if validate_plugin_component(plugin_id, "Plugin ID").is_err() {
230            return false;
231        }
232        let plugin_dir = self.plugins_dir.join(plugin_id);
233        fs::symlink_metadata(plugin_dir)
234            .await
235            .is_ok_and(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
236    }
237
238    fn entrypoint_path(&self, plugin_dir: &Path, manifest: &PluginManifest) -> Result<PathBuf> {
239        validate_relative_path(&manifest.entrypoint, "Plugin entrypoint")?;
240        let path = plugin_dir.join(&manifest.entrypoint);
241        if let Some(parent) = path.parent() {
242            VtCodePaths::ensure_user_dir(parent)
243                .with_context(|| format!("Failed to create plugin entrypoint directory: {}", parent.display()))?;
244        }
245        Ok(path)
246    }
247}
248
249fn validate_plugin_component(value: &str, label: &str) -> Result<()> {
250    let mut components = Path::new(value).components();
251    if value.trim().is_empty()
252        || !matches!(components.next(), Some(Component::Normal(_)))
253        || components.next().is_some()
254    {
255        bail!("{label} must be one normal path component: {value}");
256    }
257    Ok(())
258}
259
260fn validate_relative_path(path: &Path, label: &str) -> Result<()> {
261    if path.as_os_str().is_empty() || !path.components().all(|component| matches!(component, Component::Normal(_))) {
262        bail!("{label} must be a non-empty relative path without traversal: {}", path.display());
263    }
264    Ok(())
265}