From fbd20ad6e488fb3b9efc1612fdffee6e672976c1 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 14 Jun 2026 02:43:45 +0300 Subject: feat: add plugin v1 system Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs. Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs. --- src/persist/plugin_registry.rs | 237 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 src/persist/plugin_registry.rs (limited to 'src/persist/plugin_registry.rs') diff --git a/src/persist/plugin_registry.rs b/src/persist/plugin_registry.rs new file mode 100644 index 00000000..19db842a --- /dev/null +++ b/src/persist/plugin_registry.rs @@ -0,0 +1,237 @@ +use std::path::{Path, PathBuf}; + +use tracing::warn; + +use crate::api::schema::InstalledPluginInfo; + +pub const MANIFEST_UNAVAILABLE_WARNING_PREFIX: &str = "manifest unavailable: "; + +fn registry_path() -> PathBuf { + crate::session::data_dir().join("plugins.json") +} + +fn save_json_to_path(path: &Path, value: &T) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(value)?; + let tmp_path = path.with_extension("json.tmp"); + std::fs::write(&tmp_path, &json)?; + if let Err(err) = std::fs::rename(&tmp_path, path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(err); + } + Ok(()) +} + +/// Atomically write `plugins.json` next to `session.json`. +pub fn save(plugins: &[InstalledPluginInfo]) -> std::io::Result<()> { + let path = registry_path(); + save_to_path(&path, plugins) +} + +pub fn save_to_path(path: &Path, plugins: &[InstalledPluginInfo]) -> std::io::Result<()> { + save_json_to_path(path, plugins) +} + +/// Load `plugins.json`. Returns an empty vec on any failure so a corrupt or +/// missing file never blocks server startup. +pub fn load() -> Vec { + load_from_path(®istry_path()) +} + +pub fn load_from_path(path: &Path) -> Vec { + if !path.exists() { + return Vec::new(); + } + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(err) => { + warn!(path = %path.display(), err = %err, "failed to read plugin registry"); + return Vec::new(); + } + }; + match serde_json::from_str::>(&content) { + Ok(entries) => entries, + Err(err) => { + warn!(path = %path.display(), err = %err, "failed to parse plugin registry, starting with empty registry"); + Vec::new() + } + } +} + +/// Re-read each entry's manifest from disk using the provided reload function. +/// +/// If the manifest parses successfully, replace cached fields but keep the +/// stored `enabled` flag. If the file is gone or unparseable, keep the stored +/// entry and append a warning so `plugin.list` surfaces it. +pub fn reload_manifests( + mut entries: Vec, + reload_fn: impl Fn(&str, bool) -> Result, +) -> Vec { + for entry in &mut entries { + entry.warnings.clear(); + match reload_fn(&entry.manifest_path, entry.enabled) { + Ok(mut fresh) => { + fresh.enabled = entry.enabled; + fresh.source = entry.source.clone(); + *entry = fresh; + } + Err(warn_msg) => { + entry + .warnings + .push(format!("{MANIFEST_UNAVAILABLE_WARNING_PREFIX}{warn_msg}")); + } + } + } + entries +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_registry_path(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir() + .join(format!( + "herdr-registry-{name}-{}-{nanos}", + std::process::id() + )) + .join("plugins.json") + } + + fn sample_plugin(id: &str) -> InstalledPluginInfo { + InstalledPluginInfo { + plugin_id: id.to_string(), + name: "Test Plugin".to_string(), + version: "0.1.0".to_string(), + description: None, + manifest_path: format!("/tmp/{id}/herdr-plugin.toml"), + plugin_root: format!("/tmp/{id}"), + enabled: true, + platforms: None, + build: vec![], + actions: vec![], + events: vec![], + panes: vec![], + link_handlers: vec![], + source: Default::default(), + warnings: vec![], + } + } + + #[test] + fn save_and_load_roundtrip() { + let path = temp_registry_path("roundtrip"); + let plugins = vec![sample_plugin("example.a"), sample_plugin("example.b")]; + + save_to_path(&path, &plugins).unwrap(); + + let loaded = load_from_path(&path); + assert_eq!(loaded.len(), 2); + let ids: Vec<_> = loaded.iter().map(|p| p.plugin_id.as_str()).collect(); + assert!(ids.contains(&"example.a")); + assert!(ids.contains(&"example.b")); + } + + #[test] + fn missing_file_returns_empty() { + let path = temp_registry_path("missing"); + let loaded = load_from_path(&path); + assert!(loaded.is_empty()); + } + + #[test] + fn corrupt_file_returns_empty_without_panic() { + let path = temp_registry_path("corrupt"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, b"this is not valid json {{{{").unwrap(); + + let loaded = load_from_path(&path); + assert!(loaded.is_empty()); + } + + #[test] + fn reload_manifests_keeps_entry_with_warning_on_missing_manifest() { + let entry = sample_plugin("example.missing"); + let entries = vec![entry]; + + let result = reload_manifests(entries, |path, _enabled| { + Err(format!("manifest not found at {path}")) + }); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].plugin_id, "example.missing"); + assert!(!result[0].warnings.is_empty()); + assert!(result[0].warnings[0].contains("manifest not found")); + } + + #[test] + fn reload_manifests_uses_fresh_parse_and_keeps_enabled_flag() { + let mut entry = sample_plugin("example.reload"); + entry.enabled = false; + entry.source = crate::api::schema::PluginSourceInfo { + kind: crate::api::schema::PluginSourceKind::Github, + owner: Some("ogulcancelik".into()), + repo: Some("herdr-plugin-examples".into()), + subdir: Some("worktree-bootstrap".into()), + requested_ref: Some("main".into()), + resolved_commit: Some("abc123".into()), + managed_path: Some("/tmp/herdr/plugins/github/example.reload".into()), + installed_unix_ms: Some(42), + }; + + let result = reload_manifests(vec![entry], |_path, _enabled| { + Ok(InstalledPluginInfo { + plugin_id: "example.reload".to_string(), + name: "Fresh Name".to_string(), + version: "0.2.0".to_string(), + description: Some("refreshed".to_string()), + manifest_path: "/tmp/example.reload/herdr-plugin.toml".to_string(), + plugin_root: "/tmp/example.reload".to_string(), + enabled: true, // caller would pass stored enabled; fresh parse returns true + platforms: None, + build: vec![], + actions: vec![], + events: vec![], + panes: vec![], + link_handlers: vec![], + source: Default::default(), + warnings: vec![], + }) + }); + + assert_eq!(result[0].name, "Fresh Name"); + assert_eq!(result[0].version, "0.2.0"); + // enabled preserved from stored entry + assert!(!result[0].enabled); + assert_eq!( + result[0].source.kind, + crate::api::schema::PluginSourceKind::Github + ); + assert_eq!(result[0].source.owner.as_deref(), Some("ogulcancelik")); + assert!(result[0].warnings.is_empty()); + } + + #[test] + fn atomic_write_temp_file_is_cleaned_up_on_rename_failure() { + // Write to a path whose parent does not yet exist, then verify the + // tmp file is removed when the write fails mid-way. Here we just + // confirm a successful write leaves no .tmp file behind. + let path = temp_registry_path("cleanup"); + save_to_path(&path, &[sample_plugin("example.cleanup")]).unwrap(); + + let tmp = path.with_extension("json.tmp"); + assert!( + !tmp.exists(), + "tmp file should be cleaned up after successful rename" + ); + assert!(path.exists()); + } +} -- cgit v1.2.3