diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 91d86b59..cceb038d 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -400,6 +400,8 @@ herdr plugin disable `plugin install` accepts GitHub shorthand only, such as `ogulcancelik/herdr-plugin-examples/worktree-bootstrap`. It uses `git`, shows a trust preview in interactive terminals, runs supported manifest build commands, and stores GitHub installs in a Herdr-managed directory. Use `--yes` for noninteractive installs. Reinstalling a GitHub-managed plugin replaces that managed checkout. Installing over a locally linked plugin is refused. Plugin manifests must declare `min_herdr_version`; install and link fail when the plugin requires a newer Herdr binary. `plugin list` is human-readable by default; pass `--json` for the raw API response. +Plugin installation and enabled state are global to the current user. A plugin installed, linked, enabled, or disabled through one Herdr session is immediately available with the same state in every session. + Local development: ```bash diff --git a/docs/next/website/src/content/docs/plugins.mdx b/docs/next/website/src/content/docs/plugins.mdx index c86dc69a..21ef2c85 100644 --- a/docs/next/website/src/content/docs/plugins.mdx +++ b/docs/next/website/src/content/docs/plugins.mdx @@ -194,6 +194,10 @@ herdr plugin log list --plugin example.layout terminals, runs supported build commands, then stores the checkout under Herdr-managed plugin data and registers it. Use `--yes` for noninteractive installs. Reinstalling a GitHub-managed plugin replaces that managed checkout. +Installed and linked plugins, including their enabled state, are global to the +current user and available in every Herdr session. Plugins installed only in a +named session on Herdr 0.7.3 must be installed or linked again. Existing plugin +config and state remain in place. Installing over a locally linked plugin is refused; unlink or uninstall the local plugin first. `plugin install` and `plugin link` create the plugin's config and state directories, and `plugin config-dir ` prints the config diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs index f6464285..657f8257 100644 --- a/src/app/api/plugins/manifest.rs +++ b/src/app/api/plugins/manifest.rs @@ -292,12 +292,7 @@ pub(super) fn normalize_plugin_source( let plugin_root = std::path::PathBuf::from(&plugin.plugin_root) .canonicalize() .map_err(|err| ("invalid_plugin_source", err.to_string()))?; - let expected = crate::session::data_dir() - .join("plugins") - .join("github") - .join(crate::api::schema::plugin_managed_path_component( - &plugin.plugin_id, - )) + let expected = crate::plugin_paths::managed_checkout_path(&plugin.plugin_id) .canonicalize() .map_err(|err| ("invalid_plugin_source", err.to_string()))?; if managed_path != expected { diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index e48a1506..d7a7b0ed 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -25,6 +25,46 @@ pub(crate) use manifest::load_plugin_manifest; use runtime::{read_capped_plugin_output, MAX_PLUGIN_COMMANDS_IN_FLIGHT}; impl App { + fn replace_installed_plugins(&mut self, entries: Vec) { + let entries = + crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| { + load_plugin_manifest(path, enabled).map_err(|(_, message)| message) + }); + self.state.installed_plugins = entries + .into_iter() + .map(|plugin| (plugin.plugin_id.clone(), plugin)) + .collect(); + } + + fn refresh_installed_plugins(&mut self) -> std::io::Result<()> { + if self.no_session { + return Ok(()); + } + let entries = crate::persist::plugin_registry::try_load()?; + self.replace_installed_plugins(entries); + Ok(()) + } + + fn update_installed_plugins( + &mut self, + mutation: impl FnOnce(&mut crate::app::state::InstalledPluginRegistry) -> T, + ) -> std::io::Result { + if self.no_session { + return Ok(mutation(&mut self.state.installed_plugins)); + } + let (result, entries) = crate::persist::plugin_registry::update(|entries| { + let mut registry = entries + .drain(..) + .map(|plugin| (plugin.plugin_id.clone(), plugin)) + .collect(); + let result = mutation(&mut registry); + *entries = registry.into_values().collect(); + result + })?; + self.replace_installed_plugins(entries); + Ok(result) + } + pub(super) fn handle_plugin_link(&mut self, id: String, params: PluginLinkParams) -> String { let mut plugin = match load_plugin_manifest(¶ms.path, params.enabled) { Ok(plugin) => plugin, @@ -39,21 +79,9 @@ impl App { if let Err(err) = env::ensure_plugin_user_dirs(&plugin) { return encode_error(id, "plugin_user_dir_create_failed", err.to_string()); } - let previous = self.state.installed_plugins.get(&plugin.plugin_id).cloned(); - self.state - .installed_plugins - .insert(plugin.plugin_id.clone(), plugin.clone()); - if let Err(err) = self.save_plugin_registry() { - match previous { - Some(previous) => { - self.state - .installed_plugins - .insert(previous.plugin_id.clone(), previous); - } - None => { - self.state.installed_plugins.remove(&plugin.plugin_id); - } - } + if let Err(err) = self.update_installed_plugins(|plugins| { + plugins.insert(plugin.plugin_id.clone(), plugin.clone()); + }) { return encode_error(id, "plugin_registry_save_failed", err.to_string()); } encode_success(id, ResponseResult::PluginLinked { plugin }) @@ -64,6 +92,9 @@ impl App { Ok(plugin_id) => plugin_id, Err(response) => return response, }; + if let Err(err) = self.refresh_installed_plugins() { + return encode_error(id, "plugin_registry_load_failed", err.to_string()); + } let mut plugins = self .state .installed_plugins @@ -87,29 +118,18 @@ impl App { let Some(plugin_id) = normalize_plugin_id(¶ms.plugin_id) else { return invalid_plugin_id(id); }; - let previous = self.state.installed_plugins.remove(&plugin_id); - let removed = previous.is_some(); - let previous_panes = if removed { - Some(self.state.plugin_panes.clone()) - } else { - None - }; + let removed = + match self.update_installed_plugins(|plugins| plugins.remove(&plugin_id).is_some()) { + Ok(removed) => removed, + Err(err) => { + return encode_error(id, "plugin_registry_save_failed", err.to_string()); + } + }; if removed { // Drop plugin_panes records for this plugin (panes keep running). self.state .plugin_panes .retain(|_, record| record.plugin_id != plugin_id); - if let Err(err) = self.save_plugin_registry() { - if let Some(previous) = previous { - self.state - .installed_plugins - .insert(plugin_id.clone(), previous); - } - if let Some(previous_panes) = previous_panes { - self.state.plugin_panes = previous_panes; - } - return encode_error(id, "plugin_registry_save_failed", err.to_string()); - } self.clear_agent_view_for_source(&format!("plugin:{plugin_id}")); } encode_success(id, ResponseResult::PluginUnlinked { plugin_id, removed }) @@ -140,6 +160,9 @@ impl App { Ok(plugin_id) => plugin_id, Err(response) => return response, }; + if let Err(err) = self.refresh_installed_plugins() { + return encode_error(id, "plugin_registry_load_failed", err.to_string()); + } let mut actions = manifest_actions(&self.state.installed_plugins) .filter(|action| { plugin_id @@ -156,6 +179,9 @@ impl App { id: String, params: PluginActionInvokeParams, ) -> String { + if let Err(err) = self.refresh_installed_plugins() { + return encode_error(id, "plugin_registry_load_failed", err.to_string()); + } let (plugin, action) = match self.find_plugin_action(params.plugin_id.as_deref(), ¶ms.action_id) { Ok(pair) => pair, @@ -200,6 +226,8 @@ impl App { &mut self, action_id: String, ) -> Result<(), String> { + self.refresh_installed_plugins() + .map_err(|err| format!("failed to load plugin registry: {err}"))?; let (plugin, action) = self .find_plugin_action(None, &action_id) .map_err(|(_, message)| message)?; @@ -230,6 +258,8 @@ impl App { url: &str, pane_id: crate::layout::PaneId, ) -> Result { + self.refresh_installed_plugins() + .map_err(|err| format!("failed to load plugin registry: {err}"))?; let Some((plugin, handler)) = self.find_plugin_link_handler(url) else { return Ok(false); }; @@ -308,6 +338,9 @@ impl App { id: String, params: PluginPaneOpenParams, ) -> String { + if let Err(err) = self.refresh_installed_plugins() { + return encode_error(id, "plugin_registry_load_failed", err.to_string()); + } let Some(plugin_id) = normalize_plugin_id(¶ms.plugin_id) else { return invalid_plugin_id(id); }; @@ -580,16 +613,21 @@ impl App { let Some(plugin_id) = normalize_plugin_id(&plugin_id) else { return invalid_plugin_id(id); }; - let Some(plugin) = self.state.installed_plugins.get_mut(&plugin_id) else { - return encode_error(id, "plugin_not_found", "plugin not found"); - }; - let previous_enabled = plugin.enabled; - plugin.enabled = enabled; - if let Err(err) = self.save_plugin_registry() { - if let Some(plugin) = self.state.installed_plugins.get_mut(&plugin_id) { - plugin.enabled = previous_enabled; + let found = match self.update_installed_plugins(|plugins| { + if let Some(plugin) = plugins.get_mut(&plugin_id) { + plugin.enabled = enabled; + true + } else { + false } - return encode_error(id, "plugin_registry_save_failed", err.to_string()); + }) { + Ok(found) => found, + Err(err) => { + return encode_error(id, "plugin_registry_save_failed", err.to_string()); + } + }; + if !found { + return encode_error(id, "plugin_not_found", "plugin not found"); } let Some(plugin) = self.state.installed_plugins.get(&plugin_id).cloned() else { return encode_error(id, "plugin_not_found", "plugin not found"); @@ -603,19 +641,6 @@ impl App { encode_success(id, ResponseResult::PluginDisabled { plugin }) } } - - pub(crate) fn save_plugin_registry(&self) -> std::io::Result<()> { - if self.no_session { - return Ok(()); - } - let plugins = self - .state - .installed_plugins - .values() - .cloned() - .collect::>(); - crate::persist::plugin_registry::save(&plugins) - } } fn invalid_plugin_id(id: String) -> String { @@ -2141,6 +2166,100 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] assert_eq!(value["error"]["code"], "plugin_manifest_unavailable"); } + #[test] + fn non_cli_plugin_consumers_refresh_global_enabled_state() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let previous_config_home = std::env::var_os("XDG_CONFIG_HOME"); + let base = unique_temp_path("plugin-global-refresh"); + std::env::set_var("XDG_CONFIG_HOME", &base); + let root = base.join("plugin"); + write_manifest(&root); + let plugin = load_plugin_manifest(&root.display().to_string(), false).unwrap(); + crate::persist::plugin_registry::update(|plugins| { + plugins.retain(|entry| entry.plugin_id != plugin.plugin_id); + plugins.push(plugin.clone()); + }) + .unwrap(); + + let mut app = test_app(); + app.no_session = false; + let workspace = crate::workspace::Workspace::test_new("plugin-refresh"); + let pane_id = workspace.tabs[0].root_pane; + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + + let make_stale = |app: &mut App| { + let mut stale = plugin.clone(); + stale.enabled = true; + app.state + .installed_plugins + .insert(stale.plugin_id.clone(), stale); + }; + + make_stale(&mut app); + assert!(app + .invoke_plugin_action_from_keybind("bootstrap".into()) + .unwrap_err() + .contains("disabled")); + + make_stale(&mut app); + assert!(!app + .invoke_plugin_link_handler_for_url( + "https://github.com/ogulcancelik/herdr/issues/1174", + pane_id, + ) + .unwrap()); + + make_stale(&mut app); + let pane = app.handle_api_request(Request { + id: "pane-disabled".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.worktree-bootstrap".into(), + entrypoint: "board".into(), + placement: Some(PluginPanePlacement::Overlay), + width: None, + height: None, + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: true, + env: std::collections::HashMap::new(), + }), + }); + let pane: serde_json::Value = serde_json::from_str(&pane).unwrap(); + assert_eq!(pane["error"]["code"], "plugin_disabled"); + + make_stale(&mut app); + let logs_before = app.state.plugin_command_logs.len(); + let workspace = app.workspace_info(0); + app.run_plugin_event_hooks(&crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::WorktreeCreated, + data: crate::api::schema::EventData::WorktreeCreated { + workspace: workspace.clone(), + worktree: crate::api::schema::WorktreeInfo { + path: "/tmp/repo".into(), + branch: Some("feature".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + open_workspace_id: Some(workspace.workspace_id), + label: "feature".into(), + }, + }, + }); + assert_eq!(app.state.plugin_command_logs.len(), logs_before); + + let _ = std::fs::remove_dir_all(&base); + match previous_config_home { + Some(previous) => std::env::set_var("XDG_CONFIG_HOME", previous), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + #[cfg(unix)] #[test] fn manifest_action_invoke_runs_command_and_captures_log() { diff --git a/src/app/api/plugins/runtime.rs b/src/app/api/plugins/runtime.rs index 71004fe5..9106886c 100644 --- a/src/app/api/plugins/runtime.rs +++ b/src/app/api/plugins/runtime.rs @@ -220,6 +220,10 @@ impl App { if !crate::api::schema::PLUGIN_HOOK_EVENT_KINDS.contains(&event.event) { return; } + if let Err(err) = self.refresh_installed_plugins() { + tracing::warn!(err = %err, "failed to refresh plugin registry before event hooks"); + return; + } let plugins = self .state .installed_plugins diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index 5c051cc6..bd162e40 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -205,7 +205,7 @@ fn plugin_install(args: &[String]) -> std::io::Result { let post_build_plugin = load_cli_plugin_manifest(&manifest_root, true)?; ensure_manifest_unchanged_after_build(&preview_plugin, &post_build_plugin)?; - let final_checkout = managed_checkout_path(&preview_plugin.plugin_id); + let final_checkout = crate::plugin_paths::managed_checkout_path(&preview_plugin.plugin_id); let backup_checkout = temp_root.join("previous-checkout"); let mut backup_moved = false; if final_checkout.exists() { @@ -296,14 +296,15 @@ fn plugin_uninstall(args: &[String]) -> std::io::Result { } } Err(err) if is_connection_error(&err) => { - let mut plugins = crate::persist::plugin_registry::load(); - let before = plugins.len(); - plugins.retain(|plugin| plugin.plugin_id != plugin_id); - if before == plugins.len() { + let (removed, _) = crate::persist::plugin_registry::update(|plugins| { + let before = plugins.len(); + plugins.retain(|plugin| plugin.plugin_id != plugin_id); + before != plugins.len() + })?; + if !removed { eprintln!("plugin not installed: {target}"); return Ok(1); } - crate::persist::plugin_registry::save(&plugins)?; } Err(err) => return Err(err), } @@ -947,12 +948,14 @@ fn register_installed_plugin( Ok(()) } Err(err) if is_connection_error(&err) => { - let mut plugins = crate::persist::plugin_registry::load(); - plugins.retain(|entry| entry.plugin_id != plugin.plugin_id); crate::plugin_paths::ensure_plugin_user_dirs(&plugin.plugin_id) .map_err(InstallFailure::Rollback)?; - plugins.push(plugin); - crate::persist::plugin_registry::save(&plugins).map_err(InstallFailure::Rollback) + crate::persist::plugin_registry::update(|plugins| { + plugins.retain(|entry| entry.plugin_id != plugin.plugin_id); + plugins.push(plugin); + }) + .map(|_| ()) + .map_err(InstallFailure::Rollback) } Err(err) => Err(InstallFailure::Rollback(err)), } @@ -1533,7 +1536,7 @@ fn confirm(prompt: &str) -> std::io::Result { } fn create_plugin_temp_dir(label: &str) -> std::io::Result { - let path = managed_plugins_dir().join(format!( + let path = crate::plugin_paths::managed_plugins_dir().join(format!( ".tmp-{label}-{}-{}", std::process::id(), current_unix_ms() @@ -1542,16 +1545,6 @@ fn create_plugin_temp_dir(label: &str) -> std::io::Result { Ok(path) } -fn managed_plugins_dir() -> PathBuf { - crate::session::data_dir().join("plugins") -} - -fn managed_checkout_path(plugin_id: &str) -> PathBuf { - managed_plugins_dir() - .join("github") - .join(crate::api::schema::plugin_managed_path_component(plugin_id)) -} - fn remove_managed_plugin_files(plugin: &InstalledPluginInfo) -> std::io::Result<()> { if plugin.source.kind != PluginSourceKind::Github { return Ok(()); @@ -1590,7 +1583,7 @@ fn is_expected_managed_path(plugin: &InstalledPluginInfo, path: &Path) -> bool { let Ok(path) = path.canonicalize() else { return false; }; - let expected = managed_checkout_path(&plugin.plugin_id); + let expected = crate::plugin_paths::managed_checkout_path(&plugin.plugin_id); let Ok(expected) = expected.canonicalize() else { return false; }; diff --git a/src/persist/plugin_registry.rs b/src/persist/plugin_registry.rs index 5cd1a1f5..04cdd845 100644 --- a/src/persist/plugin_registry.rs +++ b/src/persist/plugin_registry.rs @@ -1,3 +1,4 @@ +use std::fs::OpenOptions; use std::path::{Path, PathBuf}; use tracing::warn; @@ -5,9 +6,29 @@ use tracing::warn; use crate::api::schema::InstalledPluginInfo; pub const MANIFEST_UNAVAILABLE_WARNING_PREFIX: &str = "manifest unavailable: "; +const REGISTRY_LOCK_FILE: &str = ".plugins.lock"; fn registry_path() -> PathBuf { - crate::session::data_dir().join("plugins.json") + crate::config::config_dir().join("plugins.json") +} + +fn registry_lock_path() -> PathBuf { + crate::config::config_dir().join(REGISTRY_LOCK_FILE) +} + +fn with_registry_lock(operation: impl FnOnce() -> std::io::Result) -> std::io::Result { + let lock_path = registry_lock_path(); + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent)?; + } + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(lock_path)?; + lock.lock()?; + operation() } fn save_json_to_path(path: &Path, value: &T) -> std::io::Result<()> { @@ -16,7 +37,7 @@ fn save_json_to_path(path: &Path, value: &T) -> st } let json = serde_json::to_string_pretty(value)?; let tmp_path = path.with_extension("json.tmp"); - std::fs::write(&tmp_path, &json)?; + std::fs::write(&tmp_path, json)?; #[cfg(windows)] if path.exists() { if let Err(err) = std::fs::remove_file(path) { @@ -31,42 +52,58 @@ fn save_json_to_path(path: &Path, value: &T) -> st 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 update( + mutation: impl FnOnce(&mut Vec) -> T, +) -> std::io::Result<(T, Vec)> { + with_registry_lock(|| { + let mut plugins = load_from_path_strict(®istry_path())?; + let result = mutation(&mut plugins); + plugins.sort_by(|left, right| left.plugin_id.cmp(&right.plugin_id)); + save_to_path(®istry_path(), &plugins)?; + Ok((result, plugins)) + }) } -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, +pub fn try_load() -> std::io::Result> { + with_registry_lock(|| load_from_path_strict(®istry_path())) +} + +/// Load the global registry. Returns an empty vec on failure so a corrupt or +/// missing file never blocks server startup; mutations still use strict reads. +pub fn load() -> Vec { + match try_load() { + Ok(plugins) => plugins, 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"); + warn!(path = %registry_path().display(), err = %err, "failed to load plugin registry"); Vec::new() } } } +#[cfg(test)] +pub fn load_from_path(path: &Path) -> Vec { + match load_from_path_strict(path) { + Ok(entries) => entries, + Err(err) => { + warn!(path = %path.display(), err = %err, "failed to read plugin registry"); + Vec::new() + } + } +} + +fn load_from_path_strict(path: &Path) -> std::io::Result> { + if !path.exists() { + return Ok(Vec::new()); + } + let content = std::fs::read_to_string(path)?; + serde_json::from_str::>(&content) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err)) +} + /// Re-read each entry's manifest from disk using the provided reload function. /// /// If the manifest parses successfully, replace cached fields but keep the @@ -160,10 +197,12 @@ mod tests { 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 corrupt = b"this is not valid json {{{{"; + std::fs::write(&path, corrupt).unwrap(); - let loaded = load_from_path(&path); - assert!(loaded.is_empty()); + assert!(load_from_path_strict(&path).is_err()); + assert!(load_from_path(&path).is_empty()); + assert_eq!(std::fs::read(path).unwrap(), corrupt); } #[test] @@ -230,22 +269,6 @@ mod tests { 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()); - } - #[test] fn save_replaces_existing_registry_file() { let path = temp_registry_path("replace-existing"); diff --git a/src/plugin_paths.rs b/src/plugin_paths.rs index 18a16a77..62eed4ff 100644 --- a/src/plugin_paths.rs +++ b/src/plugin_paths.rs @@ -2,9 +2,18 @@ use std::path::{Path, PathBuf}; const PLUGIN_CONFIG_PATH_COMPONENT_MAX_CHARS: usize = 120; +pub(crate) fn managed_plugins_dir() -> PathBuf { + crate::config::config_dir().join("plugins") +} + +pub(crate) fn managed_checkout_path(plugin_id: &str) -> PathBuf { + managed_plugins_dir() + .join("github") + .join(crate::api::schema::plugin_managed_path_component(plugin_id)) +} + pub(crate) fn plugin_config_dir(plugin_id: &str) -> PathBuf { - crate::config::config_dir() - .join("plugins") + managed_plugins_dir() .join("config") .join(plugin_config_path_component(plugin_id)) } @@ -37,7 +46,7 @@ fn ensure_plugin_config_dir(plugin_id: &str) -> std::io::Result<()> { } fn legacy_plugin_config_dirs(plugin_id: &str) -> Vec { - let plugins_dir = crate::config::config_dir().join("plugins"); + let plugins_dir = managed_plugins_dir(); let old_unhashed = (!matches!(plugin_id, "config" | "github")).then(|| plugins_dir.join(plugin_id)); let current_hashed = diff --git a/tests/cli/plugins.rs b/tests/cli/plugins.rs index ab1dd21a..f76ae0a0 100644 --- a/tests/cli/plugins.rs +++ b/tests/cli/plugins.rs @@ -1,5 +1,194 @@ use super::harness::*; +#[test] +fn named_sessions_share_live_plugin_registry() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let first_dir = base.join("plugins").join("first"); + let second_dir = base.join("plugins").join("second"); + for (dir, id) in [ + (&first_dir, "example.first"), + (&second_dir, "example.second"), + ] { + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join("herdr-plugin.toml"), + format!( + "id = \"{id}\"\nname = \"{id}\"\nversion = \"0.1.0\"\nmin_herdr_version = \"0.6.10\"\n\n[[actions]]\nid = \"run\"\ntitle = \"Run\"\ncommand = [\"sh\", \"-c\", \"echo run\"]\n" + ), + ) + .unwrap(); + } + + let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha"); + let beta = spawn_named_server(&config_home, &runtime_dir, "beta"); + wait_for_socket( + &named_session_socket(&config_home, "alpha"), + Duration::from_secs(5), + ); + wait_for_socket( + &named_session_socket(&config_home, "beta"), + Duration::from_secs(5), + ); + + std::thread::scope(|scope| { + let first = scope.spawn(|| { + run_named_cli_json( + &config_home, + &runtime_dir, + &[ + "--session", + "alpha", + "plugin", + "link", + first_dir.to_str().unwrap(), + ], + ) + }); + let second = scope.spawn(|| { + run_named_cli_json( + &config_home, + &runtime_dir, + &[ + "--session", + "beta", + "plugin", + "link", + second_dir.to_str().unwrap(), + ], + ) + }); + assert_eq!( + first.join().unwrap()["result"]["plugin"]["plugin_id"], + "example.first" + ); + assert_eq!( + second.join().unwrap()["result"]["plugin"]["plugin_id"], + "example.second" + ); + }); + + let beta_list = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "beta", "plugin", "list", "--json"], + ); + assert_eq!(beta_list["result"]["plugins"].as_array().unwrap().len(), 2); + + run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "beta", "plugin", "disable", "example.first"], + ); + let disabled = run_named_cli( + &config_home, + &runtime_dir, + &[ + "--session", + "alpha", + "plugin", + "action", + "invoke", + "run", + "--plugin", + "example.first", + ], + ); + assert_eq!(disabled.status.code(), Some(1)); + let disabled_output = format!( + "{}{}", + String::from_utf8_lossy(&disabled.stdout), + String::from_utf8_lossy(&disabled.stderr) + ); + assert!(disabled_output.contains("disabled"), "{disabled_output}"); + + let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "alpha"]); + let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "beta"]); + drop(alpha); + drop(beta); + cleanup_test_base(&base); +} + +#[test] +fn plugin_install_through_named_server_is_global() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let source_repo = base.join("source-repo"); + let plugin_dir = source_repo.join("global-plugin"); + fs::create_dir_all(&plugin_dir).unwrap(); + create_committed_repo(&source_repo); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.global-plugin" +name = "Global Plugin" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos", "windows"] +"#, + ) + .unwrap(); + run_git(&source_repo, &["add", "global-plugin/herdr-plugin.toml"]); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); + + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/example/plugins.git\n", + source_repo.display() + ), + ) + .unwrap(); + + let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha"); + wait_for_socket( + &named_session_socket(&config_home, "alpha"), + Duration::from_secs(5), + ); + let install = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "alpha", + "plugin", + "install", + "example/plugins/global-plugin", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!( + install.status.success(), + "install failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); + + let beta_list = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "beta", "plugin", "list", "--json"], + ); + assert_eq!( + beta_list["result"]["plugins"][0]["plugin_id"], + "example.global-plugin" + ); + let managed_path = PathBuf::from( + beta_list["result"]["plugins"][0]["source"]["managed_path"] + .as_str() + .unwrap(), + ); + assert!(managed_path.starts_with(managed_github_plugin_dir(&config_home))); + + let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "alpha"]); + drop(alpha); + cleanup_test_base(&base); +} + #[test] fn plugin_link_list_unlink_cli_smoke_test() { let base = unique_test_dir(); @@ -212,7 +401,7 @@ command = ["sh", "-c", "echo bootstrap"] let listed = run_named_cli_json( &config_home, &runtime_dir, - &["--session", "plugins", "plugin", "list", "--json"], + &["--session", "other", "plugin", "list", "--json"], ); let plugin = &listed["result"]["plugins"][0]; assert_eq!(plugin["plugin_id"], "example.worktree-bootstrap"); @@ -223,6 +412,7 @@ command = ["sh", "-c", "echo bootstrap"] assert!(plugin["source"]["resolved_commit"].as_str().is_some()); let managed_path = PathBuf::from(plugin["source"]["managed_path"].as_str().unwrap()); assert!(managed_path.exists(), "managed checkout should exist"); + assert!(managed_path.starts_with(managed_github_plugin_dir(&config_home))); assert!( managed_path .join("worktree-bootstrap") @@ -243,7 +433,7 @@ command = ["sh", "-c", "echo bootstrap"] &runtime_dir, &[ "--session", - "plugins", + "third", "plugin", "uninstall", "example.worktree-bootstrap", @@ -263,7 +453,7 @@ command = ["sh", "-c", "echo bootstrap"] let listed = run_named_cli_json( &config_home, &runtime_dir, - &["--session", "plugins", "plugin", "list", "--json"], + &["--session", "other", "plugin", "list", "--json"], ); assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());