mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+174
-55
@@ -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(¶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<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(¶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::<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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-22
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(®istry_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(®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<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(®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<InstalledPluginInfo> {
|
||||
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::<Vec<InstalledPluginInfo>>(&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<InstalledPluginInfo> {
|
||||
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<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]
|
||||
@@ -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");
|
||||
|
||||
+12
-3
@@ -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 =
|
||||
|
||||
+193
-3
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user