aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-20 22:58:49 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-20 23:04:15 +0300
commit69d07dba84411db33bf1f5191a69f80e5069c6ea (patch)
treee954eefb66346a37cfff8f8d68aaf91d74e18c3e
parentd956a6b56dabac9690260e50a9e03372510877d4 (diff)
fix: make plugin state global across sessions
refs #1174
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx2
-rw-r--r--docs/next/website/src/content/docs/plugins.mdx4
-rw-r--r--src/app/api/plugins/manifest.rs7
-rw-r--r--src/app/api/plugins/mod.rs229
-rw-r--r--src/app/api/plugins/runtime.rs4
-rw-r--r--src/cli/plugin.rs37
-rw-r--r--src/persist/plugin_registry.rs107
-rw-r--r--src/plugin_paths.rs15
-rw-r--r--tests/cli/plugins.rs196
9 files changed, 470 insertions, 131 deletions
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_id>
`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 <id>` 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<InstalledPluginInfo>) {
+ 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<T>(
+ &mut self,
+ mutation: impl FnOnce(&mut crate::app::state::InstalledPluginRegistry) -> T,
+ ) -> std::io::Result<T> {
+ 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(&params.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(&params.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(), &params.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<bool, String> {
+ 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(&params.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::<Vec<_>>();
- 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<i32> {
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<i32> {
}
}
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<bool> {
}
fn create_plugin_temp_dir(label: &str) -> std::io::Result<PathBuf> {
- 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<PathBuf> {
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<T>(operation: impl FnOnce() -> std::io::Result<T>) -> std::io::Result<T> {
+ 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<T: serde::Serialize + ?Sized>(path: &Path, value: &T) -> std::io::Result<()> {
@@ -16,7 +37,7 @@ fn save_json_to_path<T: serde::Serialize + ?Sized>(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<T: serde::Serialize + ?Sized>(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<InstalledPluginInfo> {
- load_from_path(&registry_path())
+pub fn update<T>(
+ mutation: impl FnOnce(&mut Vec<InstalledPluginInfo>) -> T,
+) -> std::io::Result<(T, Vec<InstalledPluginInfo>)> {
+ with_registry_lock(|| {
+ let mut plugins = load_from_path_strict(&registry_path())?;
+ let result = mutation(&mut plugins);
+ plugins.sort_by(|left, right| left.plugin_id.cmp(&right.plugin_id));
+ save_to_path(&registry_path(), &plugins)?;
+ Ok((result, plugins))
+ })
}
-pub fn load_from_path(path: &Path) -> Vec<InstalledPluginInfo> {
- 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<Vec<InstalledPluginInfo>> {
+ with_registry_lock(|| load_from_path_strict(&registry_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<InstalledPluginInfo> {
+ match try_load() {
+ Ok(plugins) => plugins,
Err(err) => {
- warn!(path = %path.display(), err = %err, "failed to read plugin registry");
- return Vec::new();
+ warn!(path = %registry_path().display(), err = %err, "failed to load plugin registry");
+ Vec::new()
}
- };
- match serde_json::from_str::<Vec<InstalledPluginInfo>>(&content) {
+ }
+}
+
+#[cfg(test)]
+pub fn load_from_path(path: &Path) -> Vec<InstalledPluginInfo> {
+ match load_from_path_strict(path) {
Ok(entries) => entries,
Err(err) => {
- warn!(path = %path.display(), err = %err, "failed to parse plugin registry, starting with empty registry");
+ warn!(path = %path.display(), err = %err, "failed to read plugin registry");
Vec::new()
}
}
}
+fn load_from_path_strict(path: &Path) -> std::io::Result<Vec<InstalledPluginInfo>> {
+ if !path.exists() {
+ return Ok(Vec::new());
+ }
+ let content = std::fs::read_to_string(path)?;
+ serde_json::from_str::<Vec<InstalledPluginInfo>>(&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]
@@ -231,22 +270,6 @@ mod tests {
}
#[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");
save_to_path(&path, &[sample_plugin("example.first")]).unwrap();
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<PathBuf> {
- 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,6 +1,195 @@
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();
let config_home = base.join("config");
@@ -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());