diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 95e41d28..13e84347 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -6,6 +6,9 @@ - Added local plugin v1 support with `plugin.link/list/unlink/enable/disable`, manifest-declared actions, event hooks, managed plugin panes, link handlers, command logs, keybinding integration, and authoring docs under Preview docs. +- Added `herdr plugin install /[/subdir...]`, `plugin uninstall`, + source metadata in `plugin.list`, offline registry fallback, and a + human-readable default `plugin list` with `--json` for scripts. - Added supporting plugin host APIs for `pane.current`, `pane.process_info`, `client.window_title.set/clear`, `layout.export/apply`, plugin pane placement, plugin invocation context/env injection, and plugin pane ownership across diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 999168f2..1be9675e 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -270,19 +270,28 @@ herdr integration status [--outdated-only] ## Plugins -Plugin commands expose the early plugin host API. They are for local workflow tools that link manifests, run manifest actions, or open managed terminal UIs. +Plugin commands install and run local executable workflow plugins. A plugin is a manifest plus out-of-process commands; Herdr owns the host surface and plugins own their implementation language. -Local manifests: +Install, list, and remove plugins: + +```bash +herdr plugin install /[/subdir...] [--ref REF] [--yes] +herdr plugin list [--plugin ID] [--json] +herdr plugin uninstall +herdr plugin enable +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, and stores GitHub installs in a Herdr-managed directory. Use `--yes` for noninteractive installs. `plugin list` is human-readable by default; pass `--json` for the raw API response. + +Local development: ```bash herdr plugin link [--disabled] -herdr plugin list [--plugin ID] -herdr plugin enable -herdr plugin disable herdr plugin unlink ``` -`plugin link` accepts a plugin directory containing `herdr-plugin.toml` or a direct manifest path. Actions and event hooks are declared in the manifest; runtime action registration is not part of v1. +`plugin link` accepts a plugin directory containing `herdr-plugin.toml` or a direct manifest path. It is still the right command while authoring or testing a plugin from a local checkout. Actions and event hooks are declared in the manifest; runtime action registration is not part of v1. Actions: diff --git a/docs/next/website/src/content/docs/plugins.mdx b/docs/next/website/src/content/docs/plugins.mdx index 229fa48f..abb8a173 100644 --- a/docs/next/website/src/content/docs/plugins.mdx +++ b/docs/next/website/src/content/docs/plugins.mdx @@ -3,9 +3,9 @@ title: Plugins description: Author local Herdr plugins with manifest actions, event hooks, and panes. --- -Herdr plugins are local executable packages. A plugin directory contains -`herdr-plugin.toml`; Herdr links that manifest, then runs declared action, -event, and pane commands out of process. +Herdr plugins are executable packages. A plugin directory contains +`herdr-plugin.toml`; Herdr installs or links that manifest, then runs declared +action, event, and pane commands out of process. ```toml id = "example.layout" @@ -37,7 +37,15 @@ pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$" action = "apply" ``` -Link the plugin: +Install an example plugin: + +```bash +herdr plugin install ogulcancelik/herdr-plugin-examples/worktree-bootstrap +herdr plugin list +herdr plugin action list --plugin example.worktree-bootstrap +``` + +When you are authoring a local plugin, link the working directory instead: ```bash herdr plugin link /path/to/plugin @@ -47,6 +55,12 @@ herdr plugin pane open --plugin example.layout --entrypoint board herdr plugin log list --plugin example.layout ``` +The example cookbook repo is `ogulcancelik/herdr-plugin-examples`. It contains +separate example plugins in subdirectories, including `worktree-bootstrap`, +`dev-layout`, `gh-link-router`, and `agent-telegram-notify`. These are examples +to copy, not maintained official plugins. A GitHub-topic marketplace is planned +for discovery later; `plugin install` is the sharing path for now. + Actions are manifest-only. Runtime action registration is not part of plugin v1. Pane commands are manifest-only too; `plugin.pane.open` selects a declared `[[panes]]` entrypoint and does not accept runtime argv. Action ids, pane ids, diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index e7a9664f..54997e4e 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -288,9 +288,9 @@ Worktree commands also emit lifecycle events. `worktree.create` emits `workspace ## Plugin APIs -The plugin API is an early host surface for local workflow tools. A plugin is a local executable package with a `herdr-plugin.toml` manifest. Actions are manifest-only; runtime action registration is not part of v1. +The plugin API is an early host surface for executable workflow tools. A plugin is a package with a `herdr-plugin.toml` manifest. Actions are manifest-only; runtime action registration is not part of v1. -Linked plugins persist across restarts. Herdr writes a `plugins.json` registry file alongside `session.json` on every `plugin.link` and `plugin.unlink`. On startup, Herdr re-reads each manifest from its original path; if the file is missing or unparseable the entry is kept with a `warnings` field so `plugin.list` surfaces it. +Installed and linked plugins persist across restarts. Herdr writes a `plugins.json` registry file alongside `session.json` on every `plugin.link` and `plugin.unlink`. The `herdr plugin install` CLI also writes the same registry when Herdr is not running, then startup loads it automatically. On startup, Herdr re-reads each manifest from its original path; if the file is missing or unparseable the entry is kept with a `warnings` field so `plugin.list` surfaces it. Event hook `on` values are validated against the known Herdr event names at link time. An unrecognised name is not an error — the link still succeeds — but the returned plugin info includes a warning (e.g. `"unknown event 'worktree.craeted'"`). Check the `warnings` field in the `plugin.link` and `plugin.list` responses. @@ -300,6 +300,12 @@ Link a local plugin manifest: {"id":"req_plugin_link","method":"plugin.link","params":{"path":"/path/to/plugin","enabled":true}} ``` +`plugin.link` also accepts optional `source` metadata. The CLI uses this when it installs from GitHub so `plugin.list` can show origin, requested ref, resolved commit, and managed checkout path: + +```json +{"id":"req_plugin_link","method":"plugin.link","params":{"path":"/managed/plugin/herdr-plugin.toml","enabled":true,"source":{"kind":"github","owner":"ogulcancelik","repo":"herdr-plugin-examples","subdir":"worktree-bootstrap","requested_ref":"main","resolved_commit":"abc123","managed_path":"/data/plugins/github/example.worktree-bootstrap","installed_unix_ms":1780000000000}}} +``` + The path can be a plugin directory containing `herdr-plugin.toml` or a direct manifest path. The manifest shape for this slice is: ```toml diff --git a/src/api/schema/plugins.rs b/src/api/schema/plugins.rs index 1d9aa823..a9786072 100644 --- a/src/api/schema/plugins.rs +++ b/src/api/schema/plugins.rs @@ -12,6 +12,8 @@ pub struct PluginLinkParams { pub path: String, #[serde(default = "super::common::default_true")] pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -50,12 +52,70 @@ pub struct InstalledPluginInfo { pub panes: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub link_handlers: Vec, + #[serde(default)] + pub source: PluginSourceInfo, /// Warnings collected at link time or on registry load (e.g. unknown event names, /// missing manifest file). Non-fatal — the entry is kept and surfaced by plugin.list. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginSourceInfo { + #[serde(default)] + pub kind: PluginSourceKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subdir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub managed_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub installed_unix_ms: Option, +} + +impl Default for PluginSourceInfo { + fn default() -> Self { + Self { + kind: PluginSourceKind::Local, + owner: None, + repo: None, + subdir: None, + requested_ref: None, + resolved_commit: None, + managed_path: None, + installed_unix_ms: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginSourceKind { + #[default] + Local, + Github, +} + +pub(crate) fn plugin_managed_path_component(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '_' + } + }) + .collect() +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PluginManifestAction { pub id: String, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index bb08371b..877ab479 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -651,6 +651,7 @@ fn plugin_link_list_unlink_round_trip() { method: Method::PluginLink(PluginLinkParams { path: "/plugins/worktree-bootstrap".into(), enabled: true, + source: None, }), }; let json = serde_json::to_string(&link).unwrap(); @@ -717,6 +718,7 @@ fn plugin_link_list_unlink_round_trip() { action: "bootstrap".into(), platforms: None, }], + source: Default::default(), warnings: vec![], }; diff --git a/src/app/api/plugins.rs b/src/app/api/plugins.rs index 65f6f25c..daaa423e 100644 --- a/src/app/api/plugins.rs +++ b/src/app/api/plugins.rs @@ -10,7 +10,7 @@ use crate::api::schema::{ PluginListParams, PluginLogListParams, PluginManifestAction, PluginManifestEventHook, PluginManifestLinkHandler, PluginManifestPane, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo, PluginPaneOpenParams, PluginPanePlacement, PluginPlatform, - PluginSetEnabledParams, PluginUnlinkParams, ResponseResult, + PluginSetEnabledParams, PluginSourceInfo, PluginSourceKind, PluginUnlinkParams, ResponseResult, }; use crate::app::App; @@ -21,10 +21,16 @@ const MAX_PLUGIN_COMMANDS_IN_FLIGHT: usize = 32; const PLUGIN_COMMAND_LOG_LIMIT: usize = 200; impl App { pub(super) fn handle_plugin_link(&mut self, id: String, params: PluginLinkParams) -> String { - let plugin = match load_plugin_manifest(¶ms.path, params.enabled) { + let mut plugin = match load_plugin_manifest(¶ms.path, params.enabled) { Ok(plugin) => plugin, Err((code, message)) => return encode_error(id, code, message), }; + if let Some(source) = params.source { + match normalize_plugin_source(&plugin, source) { + Ok(source) => plugin.source = source, + Err((code, message)) => return encode_error(id, code, message), + } + } let previous = self.state.installed_plugins.get(&plugin.plugin_id).cloned(); self.state .installed_plugins @@ -1543,10 +1549,53 @@ pub(crate) fn load_plugin_manifest( events, panes, link_handlers, + source: Default::default(), warnings, }) } +fn normalize_plugin_source( + plugin: &InstalledPluginInfo, + source: PluginSourceInfo, +) -> Result { + if source.kind == PluginSourceKind::Local { + return Ok(source); + } + let Some(managed_path) = source.managed_path.as_deref() else { + return Err(( + "invalid_plugin_source", + "GitHub plugin source requires managed_path".to_string(), + )); + }; + let managed_path = std::path::PathBuf::from(managed_path) + .canonicalize() + .map_err(|err| ("invalid_plugin_source", err.to_string()))?; + 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, + )) + .canonicalize() + .map_err(|err| ("invalid_plugin_source", err.to_string()))?; + if managed_path != expected { + return Err(( + "invalid_plugin_source", + "GitHub plugin managed_path does not match the plugin id".to_string(), + )); + } + if !plugin_root.starts_with(&managed_path) { + return Err(( + "invalid_plugin_source", + "plugin manifest is not inside the managed checkout".to_string(), + )); + } + Ok(source) +} + fn reject_duplicate_action_ids( actions: &[PluginManifestAction], ) -> Result<(), (&'static str, String)> { @@ -2033,6 +2082,7 @@ action = "bootstrap" method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, + source: None, }), }); assert!( @@ -2052,6 +2102,7 @@ action = "bootstrap" method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, + source: None, }), }); let ResponseResult::PluginLinked { plugin } = response_result(&link) else { @@ -2110,6 +2161,35 @@ action = "bootstrap" let _ = std::fs::remove_dir_all(root); } + #[test] + fn plugin_link_rejects_invalid_github_source_path() { + let mut app = test_app(); + let root = unique_temp_path("plugin-invalid-source"); + write_manifest(&root); + + let response = app.handle_api_request(Request { + id: "link-invalid-source".into(), + method: Method::PluginLink(PluginLinkParams { + path: root.display().to_string(), + enabled: true, + source: Some(PluginSourceInfo { + kind: PluginSourceKind::Github, + owner: Some("ogulcancelik".into()), + repo: Some("herdr-plugin-examples".into()), + subdir: Some("worktree-bootstrap".into()), + requested_ref: None, + resolved_commit: Some("abc123".into()), + managed_path: Some(root.display().to_string()), + installed_unix_ms: Some(42), + }), + }), + }); + let value: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(value["error"]["code"], "invalid_plugin_source"); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn link_rejects_duplicate_action_ids() { let root = unique_temp_path("plugin-duplicate-action"); @@ -2930,6 +3010,7 @@ action = "open" method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, + source: None, }), }); let value: serde_json::Value = serde_json::from_str(&response).unwrap(); @@ -2971,6 +3052,7 @@ action = "missing" method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, + source: None, }), }); let value: serde_json::Value = serde_json::from_str(&response).unwrap(); @@ -3088,6 +3170,7 @@ command = ["show-ctx"] method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: false, + source: None, }), }); assert!( @@ -3147,6 +3230,7 @@ command = ["sh", "-c", "echo ok"] method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, + source: None, }), }); @@ -3474,6 +3558,7 @@ command = ["act"] method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, + source: None, }), }); assert!(link.contains("plugin_linked"), "link failed: {link}"); @@ -3538,6 +3623,7 @@ command = ["act"] method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, + source: None, }), }); assert!(link.contains("plugin_linked"), "link failed: {link}"); @@ -3584,6 +3670,7 @@ command = ["act"] method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, + source: None, }), }); let ResponseResult::PluginLinked { plugin } = response_result(&link) else { diff --git a/src/app/mod.rs b/src/app/mod.rs index 4906897b..356ecbab 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -56,6 +56,13 @@ use crate::events::AppEvent; pub use state::{AppState, Mode, ToastKind, ViewState}; +pub(crate) fn load_plugin_manifest( + path: &str, + enabled: bool, +) -> Result { + api::plugins::load_plugin_manifest(path, enabled) +} + /// Full application: AppState + runtime concerns (event channels, async I/O). #[derive(Debug, Clone)] pub(crate) struct OverlayPaneState { diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index ecb41772..aecf2055 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -1,10 +1,15 @@ use std::collections::HashMap; +use std::io::{self, IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::api::schema::{ - Method, PluginActionInvokeParams, PluginActionListParams, PluginInvocationContext, - PluginLinkParams, PluginListParams, PluginLogListParams, PluginPaneCloseParams, - PluginPaneFocusParams, PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, - PluginUnlinkParams, Request, SplitDirection, + InstalledPluginInfo, Method, PluginActionInvokeParams, PluginActionListParams, + PluginInvocationContext, PluginLinkParams, PluginListParams, PluginLogListParams, + PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneOpenParams, PluginPanePlacement, + PluginSetEnabledParams, PluginSourceInfo, PluginSourceKind, PluginUnlinkParams, Request, + ResponseResult, SplitDirection, SuccessResponse, }; pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result { @@ -14,6 +19,8 @@ pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result { }; match subcommand { + "install" => plugin_install(&args[1..]), + "uninstall" => plugin_uninstall(&args[1..]), "link" => plugin_link(&args[1..]), "list" => plugin_list(&args[1..]), "unlink" => plugin_unlink(&args[1..]), @@ -57,14 +64,23 @@ fn plugin_link(args: &[String]) -> std::io::Result { } } } - print_plugin_response(Method::PluginLink(PluginLinkParams { path, enabled })) + print_plugin_response(Method::PluginLink(PluginLinkParams { + path, + enabled, + source: None, + })) } fn plugin_list(args: &[String]) -> std::io::Result { let mut plugin_id = None; + let mut json = false; let mut index = 0; while index < args.len() { match args[index].as_str() { + "--json" => { + json = true; + index += 1; + } "--plugin" => { let Some(value) = required_value(args, &mut index, "--plugin") else { return Ok(2); @@ -77,7 +93,19 @@ fn plugin_list(args: &[String]) -> std::io::Result { } } } - print_plugin_response(Method::PluginList(PluginListParams { plugin_id })) + let params = PluginListParams { plugin_id }; + let response = match super::send_request(&Request { + id: "cli:plugin".into(), + method: Method::PluginList(params.clone()), + }) { + Ok(response) => response, + Err(err) if is_connection_error(&err) => offline_plugin_list_response(¶ms)?, + Err(err) => return Err(err), + }; + if json { + return super::print_response(&response); + } + print_plugin_list_human(&response) } fn plugin_unlink(args: &[String]) -> std::io::Result { @@ -94,6 +122,153 @@ fn plugin_unlink(args: &[String]) -> std::io::Result { })) } +fn plugin_install(args: &[String]) -> std::io::Result { + let Some(source_arg) = args.first() else { + eprintln!("usage: herdr plugin install /[/subdir...] [--ref REF] [--yes]"); + return Ok(2); + }; + let source = match GithubPluginSource::parse(source_arg) { + Ok(source) => source, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; + let mut requested_ref = None; + let mut yes = false; + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--ref" => { + let Some(value) = required_value(args, &mut index, "--ref") else { + return Ok(2); + }; + requested_ref = Some(value); + } + "--yes" | "-y" => { + yes = true; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + if !yes && !io::stdin().is_terminal() { + eprintln!("remote plugin install requires --yes when stdin is not interactive"); + return Ok(2); + } + + let temp_root = create_plugin_temp_dir("install")?; + let checkout = temp_root.join("checkout"); + let install_result = (|| { + git_checkout(&source, requested_ref.as_deref(), &checkout)?; + let resolved_commit = git_output(&checkout, ["rev-parse", "HEAD"])?; + let manifest_root = source.manifest_root(&checkout); + let preview_plugin = load_cli_plugin_manifest(&manifest_root, true)?; + let existing = installed_plugin_info(&preview_plugin.plugin_id)?; + ensure_replacement_allowed(&preview_plugin, existing.as_ref())?; + + let mut source_info = + source.to_source_info(requested_ref, resolved_commit, None, current_unix_ms()); + print_install_preview(&preview_plugin, &source_info, existing.as_ref()); + if !yes && !confirm("Install this plugin?")? { + eprintln!("plugin install cancelled"); + return Ok(0); + } + + let final_checkout = managed_checkout_path(&preview_plugin.plugin_id); + let backup_checkout = temp_root.join("previous-checkout"); + let mut backup_moved = false; + if final_checkout.exists() { + std::fs::rename(&final_checkout, &backup_checkout)?; + backup_moved = true; + } + let install_attempt = (|| { + if let Some(parent) = final_checkout.parent() { + std::fs::create_dir_all(parent).map_err(InstallFailure::Rollback)?; + } + std::fs::rename(&checkout, &final_checkout).map_err(InstallFailure::Rollback)?; + + source_info.managed_path = Some(final_checkout.display().to_string()); + let final_manifest_root = source.manifest_root(&final_checkout); + let mut plugin = load_cli_plugin_manifest(&final_manifest_root, true) + .map_err(InstallFailure::Rollback)?; + plugin.source = source_info.clone(); + register_installed_plugin(plugin.clone(), source_info.clone())?; + Ok::(plugin) + })(); + let plugin = match install_attempt { + Ok(plugin) => plugin, + Err(InstallFailure::Rollback(err)) => { + let _ = std::fs::remove_dir_all(&final_checkout); + if backup_moved && backup_checkout.exists() { + let _ = std::fs::rename(&backup_checkout, &final_checkout); + } + return Err(err); + } + Err(InstallFailure::KeepCheckout(err)) => return Err(err), + }; + println!("Installed {} from {}.", plugin.plugin_id, source.display()); + Ok(0) + })(); + let _ = std::fs::remove_dir_all(&temp_root); + install_result +} + +fn plugin_uninstall(args: &[String]) -> std::io::Result { + let Some(plugin_id) = args.first() else { + eprintln!("usage: herdr plugin uninstall "); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr plugin uninstall "); + return Ok(2); + } + + let existing = match live_installed_plugin_info(plugin_id) { + Ok(plugin) => plugin, + Err(err) if is_connection_error(&err) => registry_plugin_info(plugin_id), + Err(err) => return Err(err), + }; + + match super::send_request(&Request { + id: "cli:plugin".into(), + method: Method::PluginUnlink(PluginUnlinkParams { + plugin_id: plugin_id.clone(), + }), + }) { + Ok(response) => { + if response.get("error").is_some() { + return super::print_response(&response); + } + if response["result"]["removed"].as_bool() == Some(false) { + eprintln!("plugin not installed: {plugin_id}"); + return Ok(1); + } + } + 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() { + eprintln!("plugin not installed: {plugin_id}"); + return Ok(1); + } + crate::persist::plugin_registry::save(&plugins)?; + } + Err(err) => return Err(err), + } + + if let Some(plugin) = existing.as_ref() { + remove_managed_plugin_files(plugin)?; + } + println!("Uninstalled {plugin_id}."); + Ok(0) +} + fn plugin_set_enabled(args: &[String], enabled: bool) -> std::io::Result { let Some(plugin_id) = args.first() else { eprintln!( @@ -446,6 +621,545 @@ fn normalize_plugin_path_arg(value: &str) -> std::io::Result { Ok(absolute.display().to_string()) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct GithubPluginSource { + owner: String, + repo: String, + subdir: Option, +} + +impl GithubPluginSource { + fn parse(value: &str) -> Result { + if value.starts_with("http://") + || value.starts_with("https://") + || value.starts_with("git@") + || value.contains(':') + { + return Err("plugin install v1 accepts only owner/repo[/subdir] shorthand".into()); + } + let parts = value.split('/').collect::>(); + if parts.len() < 2 { + return Err("usage: herdr plugin install /[/subdir...]".into()); + } + let owner = parts[0]; + let repo = parts[1]; + validate_github_segment("owner", owner)?; + validate_github_segment("repo", repo)?; + let subdir_parts = &parts[2..]; + for part in subdir_parts { + validate_subdir_segment(part)?; + } + let subdir = if subdir_parts.is_empty() { + None + } else { + Some(subdir_parts.join("/")) + }; + Ok(Self { + owner: owner.to_string(), + repo: repo.to_string(), + subdir, + }) + } + + fn remote_url(&self) -> String { + format!("https://github.com/{}/{}.git", self.owner, self.repo) + } + + fn display(&self) -> String { + match &self.subdir { + Some(subdir) => format!("{}/{}/{}", self.owner, self.repo, subdir), + None => format!("{}/{}", self.owner, self.repo), + } + } + + fn manifest_root(&self, checkout: &Path) -> PathBuf { + match &self.subdir { + Some(subdir) => checkout.join(subdir), + None => checkout.to_path_buf(), + } + } + + fn to_source_info( + &self, + requested_ref: Option, + resolved_commit: String, + managed_path: Option, + installed_unix_ms: u64, + ) -> PluginSourceInfo { + PluginSourceInfo { + kind: PluginSourceKind::Github, + owner: Some(self.owner.clone()), + repo: Some(self.repo.clone()), + subdir: self.subdir.clone(), + requested_ref, + resolved_commit: Some(resolved_commit), + managed_path, + installed_unix_ms: Some(installed_unix_ms), + } + } +} + +fn ensure_replacement_allowed( + plugin: &InstalledPluginInfo, + existing: Option<&InstalledPluginInfo>, +) -> std::io::Result<()> { + let Some(existing) = existing else { + return Ok(()); + }; + if existing.source.kind != PluginSourceKind::Github { + return Err(std::io::Error::other(format!( + "plugin {} is already linked from a local path; uninstall/unlink it before installing from GitHub", + plugin.plugin_id + ))); + } + Ok(()) +} + +fn validate_github_segment(label: &str, value: &str) -> Result<(), String> { + if value.is_empty() { + return Err(format!("GitHub {label} must not be empty")); + } + if value == "." || value == ".." { + return Err(format!("GitHub {label} is invalid: {value}")); + } + if !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + { + return Err(format!( + "GitHub {label} contains invalid characters: {value}" + )); + } + Ok(()) +} + +fn validate_subdir_segment(value: &str) -> Result<(), String> { + if value.is_empty() || value == "." || value == ".." { + return Err(format!("invalid plugin subdir segment: {value}")); + } + if value.contains('\\') || value.contains('\0') { + return Err(format!("invalid plugin subdir segment: {value}")); + } + Ok(()) +} + +fn git_checkout( + source: &GithubPluginSource, + requested_ref: Option<&str>, + checkout: &Path, +) -> std::io::Result<()> { + std::fs::create_dir_all(checkout)?; + run_git(Some(checkout), ["init"])?; + run_git( + Some(checkout), + ["remote", "add", "origin", &source.remote_url()], + )?; + match requested_ref { + Some(reference) => { + run_git( + Some(checkout), + ["fetch", "--depth", "1", "origin", reference], + )?; + } + None => { + run_git(Some(checkout), ["fetch", "--depth", "1", "origin", "HEAD"])?; + } + } + run_git(Some(checkout), ["checkout", "--detach", "FETCH_HEAD"])?; + Ok(()) +} + +fn run_git(cwd: Option<&Path>, args: [&str; N]) -> std::io::Result<()> { + let mut command = Command::new("git"); + command.args(args); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + command.stdin(Stdio::null()); + let output = command.output()?; + if output.status.success() { + return Ok(()); + } + Err(std::io::Error::other(command_failure_message( + "git", &output, + ))) +} + +fn git_output(cwd: &Path, args: [&str; N]) -> std::io::Result { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .stdin(Stdio::null()) + .output()?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + Err(std::io::Error::other(command_failure_message( + "git", &output, + ))) +} + +fn command_failure_message(program: &str, output: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + format!("{program} failed with status {}", output.status) + } else { + format!("{program} failed with status {}: {stderr}", output.status) + } +} + +fn load_cli_plugin_manifest(path: &Path, enabled: bool) -> std::io::Result { + crate::app::load_plugin_manifest(&path.display().to_string(), enabled) + .map_err(|(_, message)| std::io::Error::other(message)) +} + +fn register_installed_plugin( + plugin: InstalledPluginInfo, + source: PluginSourceInfo, +) -> Result<(), InstallFailure> { + let request = Request { + id: "cli:plugin".into(), + method: Method::PluginLink(PluginLinkParams { + path: plugin.manifest_path.clone(), + enabled: plugin.enabled, + source: Some(source.clone()), + }), + }; + match super::send_request(&request) { + Ok(response) => { + if response.get("error").is_some() { + return Err(InstallFailure::Rollback(std::io::Error::other( + serde_json::to_string(&response).unwrap(), + ))); + } + if let Err(err) = + verify_plugin_link_source_response(response, &plugin.plugin_id, &source) + { + let unlink = super::send_request(&Request { + id: "cli:plugin".into(), + method: Method::PluginUnlink(PluginUnlinkParams { + plugin_id: plugin.plugin_id.clone(), + }), + }); + match unlink { + Ok(response) if response.get("error").is_none() => { + return Err(InstallFailure::Rollback(err)); + } + Ok(response) => { + return Err(InstallFailure::KeepCheckout(std::io::Error::other( + format!( + "{err}; failed to undo incompatible plugin registration: {}", + serde_json::to_string(&response).unwrap() + ), + ))); + } + Err(unlink_err) => { + return Err(InstallFailure::KeepCheckout(std::io::Error::other( + format!( + "{err}; failed to undo incompatible plugin registration: {unlink_err}" + ), + ))); + } + } + } + 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); + plugins.push(plugin); + crate::persist::plugin_registry::save(&plugins).map_err(InstallFailure::Rollback) + } + Err(err) => Err(InstallFailure::Rollback(err)), + } +} + +#[derive(Debug)] +enum InstallFailure { + Rollback(std::io::Error), + KeepCheckout(std::io::Error), +} + +fn verify_plugin_link_source_response( + response: serde_json::Value, + plugin_id: &str, + expected: &PluginSourceInfo, +) -> std::io::Result<()> { + let parsed: SuccessResponse = + serde_json::from_value(response).map_err(std::io::Error::other)?; + let ResponseResult::PluginLinked { plugin } = parsed.result else { + return Err(std::io::Error::other("expected plugin_linked response")); + }; + if plugin.plugin_id != plugin_id + || plugin.source.kind != PluginSourceKind::Github + || plugin.source.owner != expected.owner + || plugin.source.repo != expected.repo + || plugin.source.subdir != expected.subdir + || plugin.source.requested_ref != expected.requested_ref + || plugin.source.resolved_commit != expected.resolved_commit + || plugin.source.managed_path != expected.managed_path + { + return Err(std::io::Error::other( + "running Herdr server did not persist GitHub plugin source metadata", + )); + } + Ok(()) +} + +fn installed_plugin_info(plugin_id: &str) -> std::io::Result> { + match live_installed_plugin_info(plugin_id) { + Ok(plugin) => Ok(plugin), + Err(err) if is_connection_error(&err) => Ok(registry_plugin_info(plugin_id)), + Err(err) => Err(err), + } +} + +fn live_installed_plugin_info(plugin_id: &str) -> std::io::Result> { + let response = super::send_request(&Request { + id: "cli:plugin".into(), + method: Method::PluginList(PluginListParams { + plugin_id: Some(plugin_id.to_string()), + }), + })?; + if response.get("error").is_some() { + return Err(std::io::Error::other( + serde_json::to_string(&response).unwrap(), + )); + } + plugin_info_from_list_response(response) +} + +fn registry_plugin_info(plugin_id: &str) -> Option { + crate::persist::plugin_registry::load() + .into_iter() + .find(|plugin| plugin.plugin_id == plugin_id) +} + +fn plugin_info_from_list_response( + response: serde_json::Value, +) -> std::io::Result> { + let parsed: SuccessResponse = + serde_json::from_value(response).map_err(std::io::Error::other)?; + let ResponseResult::PluginList { mut plugins } = parsed.result else { + return Err(std::io::Error::other("expected plugin_list response")); + }; + Ok(plugins.pop()) +} + +fn offline_plugin_list_response(params: &PluginListParams) -> std::io::Result { + let entries = crate::persist::plugin_registry::load(); + let mut plugins = + crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| { + crate::app::load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg) + }) + .into_iter() + .filter(|plugin| { + params + .plugin_id + .as_deref() + .is_none_or(|plugin_id| plugin.plugin_id == plugin_id) + }) + .collect::>(); + plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); + serde_json::to_value(SuccessResponse { + id: "cli:plugin".into(), + result: ResponseResult::PluginList { plugins }, + }) + .map_err(std::io::Error::other) +} + +fn print_plugin_list_human(response: &serde_json::Value) -> std::io::Result { + if response.get("error").is_some() { + return super::print_response(response); + } + let parsed: SuccessResponse = + serde_json::from_value(response.clone()).map_err(std::io::Error::other)?; + let ResponseResult::PluginList { plugins } = parsed.result else { + return super::print_response(response); + }; + if plugins.is_empty() { + println!("No plugins installed."); + return Ok(0); + } + println!( + "{} plugin{} installed:", + plugins.len(), + if plugins.len() == 1 { "" } else { "s" } + ); + for plugin in plugins { + let enabled = if plugin.enabled { + "enabled" + } else { + "disabled" + }; + let warning = if plugin.warnings.is_empty() { + String::new() + } else { + format!("; {} warning(s)", plugin.warnings.len()) + }; + println!( + "- {} ({}) {} [{}{}]", + plugin.plugin_id, + plugin.name, + enabled, + source_display(&plugin), + warning + ); + for warning in plugin.warnings { + println!(" warning: {warning}"); + } + } + Ok(0) +} + +fn source_display(plugin: &InstalledPluginInfo) -> String { + match plugin.source.kind { + PluginSourceKind::Github => { + let owner = plugin.source.owner.as_deref().unwrap_or("unknown"); + let repo = plugin.source.repo.as_deref().unwrap_or("unknown"); + let subdir = plugin + .source + .subdir + .as_deref() + .map(|subdir| format!("/{subdir}")) + .unwrap_or_default(); + let reference = plugin + .source + .requested_ref + .as_deref() + .or(plugin.source.resolved_commit.as_deref()) + .unwrap_or("unknown"); + format!("github:{owner}/{repo}{subdir}@{reference}") + } + PluginSourceKind::Local => format!("local:{}", plugin.plugin_root), + } +} + +fn print_install_preview( + plugin: &InstalledPluginInfo, + source: &PluginSourceInfo, + existing: Option<&InstalledPluginInfo>, +) { + eprintln!("Plugin install preview:"); + eprintln!(" id: {}", plugin.plugin_id); + eprintln!(" name: {}", plugin.name); + eprintln!(" version: {}", plugin.version); + if let (Some(owner), Some(repo)) = (&source.owner, &source.repo) { + let subdir = source + .subdir + .as_deref() + .map(|subdir| format!("/{subdir}")) + .unwrap_or_default(); + eprintln!(" source: {owner}/{repo}{subdir}"); + } + if let Some(reference) = &source.requested_ref { + eprintln!(" ref: {reference}"); + } + if let Some(commit) = &source.resolved_commit { + eprintln!(" commit: {commit}"); + } + eprintln!(" actions: {}", plugin.actions.len()); + eprintln!(" events: {}", plugin.events.len()); + eprintln!(" panes: {}", plugin.panes.len()); + eprintln!(" link handlers: {}", plugin.link_handlers.len()); + for action in &plugin.actions { + eprintln!(" action {}: {}", action.id, action.command.join(" ")); + } + for event in &plugin.events { + eprintln!(" event {}: {}", event.on, event.command.join(" ")); + } + for pane in &plugin.panes { + eprintln!(" pane {}: {}", pane.id, pane.command.join(" ")); + } + for warning in &plugin.warnings { + eprintln!(" warning: {warning}"); + } + if let Some(existing) = existing { + eprintln!( + " replaces: {} from {}", + existing.plugin_id, + source_display(existing) + ); + } +} + +fn confirm(prompt: &str) -> std::io::Result { + eprint!("{prompt} [y/N] "); + io::stderr().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + Ok(matches!(line.trim(), "y" | "Y" | "yes" | "YES" | "Yes")) +} + +fn create_plugin_temp_dir(label: &str) -> std::io::Result { + let path = managed_plugins_dir().join(format!( + ".tmp-{label}-{}-{}", + std::process::id(), + current_unix_ms() + )); + std::fs::create_dir_all(&path)?; + 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(()); + } + let Some(path) = plugin.source.managed_path.as_deref() else { + return Ok(()); + }; + let path = PathBuf::from(path); + if !path.exists() { + return Ok(()); + } + if !is_expected_managed_path(plugin, &path) { + return Err(std::io::Error::other(format!( + "refusing to delete unmanaged plugin path: {}", + path.display() + ))); + } + std::fs::remove_dir_all(path) +} + +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 Ok(expected) = expected.canonicalize() else { + return false; + }; + path == expected +} + +fn current_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn is_connection_error(err: &std::io::Error) -> bool { + matches!( + err.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::BrokenPipe + ) +} + fn print_plugin_response(method: Method) -> std::io::Result { super::print_response(&super::send_request(&Request { id: "cli:plugin".into(), @@ -455,8 +1169,10 @@ fn print_plugin_response(method: Method) -> std::io::Result { fn print_plugin_help() { eprintln!("herdr plugin commands:"); + eprintln!(" herdr plugin install /[/subdir...] [--ref REF] [--yes]"); + eprintln!(" herdr plugin uninstall "); eprintln!(" herdr plugin link [--disabled]"); - eprintln!(" herdr plugin list [--plugin ID]"); + eprintln!(" herdr plugin list [--plugin ID] [--json]"); eprintln!(" herdr plugin unlink "); eprintln!(" herdr plugin enable "); eprintln!(" herdr plugin disable "); @@ -477,3 +1193,46 @@ fn print_plugin_pane_help() { eprintln!(" herdr plugin pane focus "); eprintln!(" herdr plugin pane close "); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_plugin_source_parses_root_repo() { + let source = GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples").unwrap(); + assert_eq!(source.owner, "ogulcancelik"); + assert_eq!(source.repo, "herdr-plugin-examples"); + assert_eq!(source.subdir, None); + assert_eq!( + source.remote_url(), + "https://github.com/ogulcancelik/herdr-plugin-examples.git" + ); + } + + #[test] + fn github_plugin_source_parses_subdir() { + let source = + GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples/worktree-bootstrap") + .unwrap(); + assert_eq!(source.owner, "ogulcancelik"); + assert_eq!(source.repo, "herdr-plugin-examples"); + assert_eq!(source.subdir.as_deref(), Some("worktree-bootstrap")); + } + + #[test] + fn github_plugin_source_rejects_non_shorthand_sources() { + for source in [ + "https://github.com/ogulcancelik/herdr-plugin-examples", + "git@github.com:ogulcancelik/herdr-plugin-examples.git", + "ogulcancelik", + "ogulcancelik/herdr-plugin-examples/../bad", + "ogulcancelik/herdr-plugin-examples//bad", + ] { + assert!( + GithubPluginSource::parse(source).is_err(), + "{source} should be rejected" + ); + } + } +} diff --git a/src/persist/plugin_registry.rs b/src/persist/plugin_registry.rs index 9eff2e87..68b478f6 100644 --- a/src/persist/plugin_registry.rs +++ b/src/persist/plugin_registry.rs @@ -74,6 +74,7 @@ pub fn reload_manifests( match reload_fn(&entry.manifest_path, entry.enabled) { Ok(mut fresh) => { fresh.enabled = entry.enabled; + fresh.source = entry.source.clone(); *entry = fresh; } Err(warn_msg) => { @@ -117,6 +118,7 @@ mod tests { events: vec![], panes: vec![], link_handlers: vec![], + source: Default::default(), warnings: vec![], } } @@ -173,6 +175,16 @@ mod tests { fn reload_manifests_uses_fresh_parse_and_keeps_enabled_flag() { let mut entry = sample_plugin("example.reload"); entry.enabled = false; + entry.source = crate::api::schema::PluginSourceInfo { + kind: crate::api::schema::PluginSourceKind::Github, + owner: Some("ogulcancelik".into()), + repo: Some("herdr-plugin-examples".into()), + subdir: Some("worktree-bootstrap".into()), + requested_ref: Some("main".into()), + resolved_commit: Some("abc123".into()), + managed_path: Some("/tmp/herdr/plugins/github/example.reload".into()), + installed_unix_ms: Some(42), + }; let result = reload_manifests(vec![entry], |_path, _enabled| { Ok(InstalledPluginInfo { @@ -188,6 +200,7 @@ mod tests { events: vec![], panes: vec![], link_handlers: vec![], + source: Default::default(), warnings: vec![], }) }); @@ -196,6 +209,11 @@ mod tests { assert_eq!(result[0].version, "0.2.0"); // enabled preserved from stored entry assert!(!result[0].enabled); + assert_eq!( + result[0].source.kind, + crate::api::schema::PluginSourceKind::Github + ); + assert_eq!(result[0].source.owner.as_deref(), Some("ogulcancelik")); assert!(result[0].warnings.is_empty()); } diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 085a9d7a..25a6b616 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -185,6 +185,25 @@ fn run_named_cli_with_socket_override( runtime_dir: &Path, args: &[&str], socket_override: Option<&Path>, +) -> std::process::Output { + run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, &[], socket_override) +} + +fn run_named_cli_with_env( + config_home: &Path, + runtime_dir: &Path, + args: &[&str], + envs: &[(&str, &Path)], +) -> std::process::Output { + run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, envs, None) +} + +fn run_named_cli_with_env_and_socket_override( + config_home: &Path, + runtime_dir: &Path, + args: &[&str], + envs: &[(&str, &Path)], + socket_override: Option<&Path>, ) -> std::process::Output { let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); command @@ -193,6 +212,9 @@ fn run_named_cli_with_socket_override( .env("XDG_RUNTIME_DIR", runtime_dir) .env_remove("HERDR_CLIENT_SOCKET_PATH") .env_remove("HERDR_ENV"); + for (key, value) in envs { + command.env(key, value); + } if let Some(socket_override) = socket_override { command.env("HERDR_SOCKET_PATH", socket_override); } else { @@ -2799,7 +2821,11 @@ command = ["sh", "-c", "sleep 5"] ); assert_eq!(linked["result"]["plugin"]["panes"][0]["id"], "board"); - let listed = run_cli_json(&socket_path, &["plugin", "list"]); + let listed_human = run_cli(&socket_path, &["plugin", "list"]); + assert!(listed_human.status.success()); + assert!(String::from_utf8_lossy(&listed_human.stdout).contains("example.layout")); + + let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]); assert_eq!(listed["result"]["type"], "plugin_list"); assert_eq!( listed["result"]["plugins"][0]["plugin_id"], @@ -2869,12 +2895,513 @@ command = ["sh", "-c", "sleep 5"] assert_eq!(unlinked["result"]["type"], "plugin_unlinked"); assert_eq!(unlinked["result"]["removed"], true); - let listed = run_cli_json(&socket_path, &["plugin", "list"]); + let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]); assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); cleanup_spawned_herdr(herdr, base); } +#[test] +fn plugin_install_list_uninstall_offline_cli_smoke_test() { + 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("worktree-bootstrap"); + fs::create_dir_all(&plugin_dir).unwrap(); + create_committed_repo(&source_repo); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.1.0" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "bootstrap" +title = "Bootstrap" +command = ["sh", "-c", "echo bootstrap"] +"#, + ) + .unwrap(); + run_git( + &source_repo, + &["add", "worktree-bootstrap/herdr-plugin.toml"], + ); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); + + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", + source_repo.display() + ), + ) + .unwrap(); + + let install = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "plugins", + "plugin", + "install", + "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", + "--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 listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "plugins", "plugin", "list", "--json"], + ); + let plugin = &listed["result"]["plugins"][0]; + assert_eq!(plugin["plugin_id"], "example.worktree-bootstrap"); + assert_eq!(plugin["source"]["kind"], "github"); + assert_eq!(plugin["source"]["owner"], "ogulcancelik"); + assert_eq!(plugin["source"]["repo"], "herdr-plugin-examples"); + assert_eq!(plugin["source"]["subdir"], "worktree-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"); + + let uninstall = run_named_cli( + &config_home, + &runtime_dir, + &[ + "--session", + "plugins", + "plugin", + "uninstall", + "example.worktree-bootstrap", + ], + ); + assert!( + uninstall.status.success(), + "uninstall failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&uninstall.stdout), + String::from_utf8_lossy(&uninstall.stderr) + ); + assert!( + !managed_path.exists(), + "managed checkout should be deleted on uninstall" + ); + + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "plugins", "plugin", "list", "--json"], + ); + assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); + + cleanup_test_base(&base); +} + +#[test] +fn plugin_install_restores_previous_checkout_when_registration_fails() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("fake-herdr.sock"); + let source_repo = base.join("source-repo"); + let plugin_dir = source_repo.join("worktree-bootstrap"); + fs::create_dir_all(&plugin_dir).unwrap(); + create_committed_repo(&source_repo); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.2.0" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "bootstrap" +title = "Bootstrap" +command = ["sh", "-c", "echo new"] +"#, + ) + .unwrap(); + run_git( + &source_repo, + &["add", "worktree-bootstrap/herdr-plugin.toml"], + ); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); + + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + let managed_checkout = config_home + .join("herdr-dev") + .join("plugins") + .join("github") + .join("example.worktree-bootstrap"); + fs::create_dir_all(&managed_checkout).unwrap(); + fs::write(managed_checkout.join("old-marker"), "old checkout\n").unwrap(); + + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", + source_repo.display() + ), + ) + .unwrap(); + + let listener = UnixListener::bind(&socket_path).unwrap(); + let managed_checkout_for_server = managed_checkout.clone(); + let server = thread::spawn(move || { + let (mut first, _) = listener.accept().unwrap(); + let mut first_line = String::new(); + let mut first_reader = BufReader::new(first.try_clone().unwrap()); + first_reader.read_line(&mut first_line).unwrap(); + let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); + assert_eq!(first_request["method"], "plugin.list"); + writeln!( + first, + "{}", + serde_json::json!({ + "id": "cli:plugin", + "result": { + "type": "plugin_list", + "plugins": [{ + "plugin_id": "example.worktree-bootstrap", + "name": "Worktree Bootstrap", + "version": "0.1.0", + "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), + "plugin_root": managed_checkout_for_server.display().to_string(), + "enabled": true, + "source": { + "kind": "github", + "owner": "ogulcancelik", + "repo": "herdr-plugin-examples", + "subdir": "worktree-bootstrap", + "resolved_commit": "old", + "managed_path": managed_checkout_for_server.display().to_string(), + "installed_unix_ms": 1 + } + }] + } + }) + ) + .unwrap(); + first.flush().unwrap(); + + let (mut second, _) = listener.accept().unwrap(); + let mut second_line = String::new(); + let mut second_reader = BufReader::new(second.try_clone().unwrap()); + second_reader.read_line(&mut second_line).unwrap(); + let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap(); + assert_eq!(second_request["method"], "plugin.link"); + second + .write_all( + br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced failure"}}"#, + ) + .unwrap(); + second.write_all(b"\n").unwrap(); + second.flush().unwrap(); + }); + + let install = run_named_cli_with_env_and_socket_override( + &config_home, + &runtime_dir, + &[ + "plugin", + "install", + "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + Some(&socket_path), + ); + assert!( + !install.status.success(), + "install should fail when plugin.link fails" + ); + server.join().unwrap(); + assert!( + managed_checkout.join("old-marker").exists(), + "old checkout should be restored after registration failure" + ); + + cleanup_test_base(&base); +} + +#[test] +fn plugin_install_rejects_server_that_drops_source_metadata() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("fake-herdr.sock"); + let source_repo = base.join("source-repo"); + let plugin_dir = source_repo.join("worktree-bootstrap"); + fs::create_dir_all(&plugin_dir).unwrap(); + create_committed_repo(&source_repo); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.1.0" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "bootstrap" +title = "Bootstrap" +command = ["sh", "-c", "echo install"] +"#, + ) + .unwrap(); + run_git( + &source_repo, + &["add", "worktree-bootstrap/herdr-plugin.toml"], + ); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); + + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + let managed_checkout = config_home + .join("herdr-dev") + .join("plugins") + .join("github") + .join("example.worktree-bootstrap"); + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", + source_repo.display() + ), + ) + .unwrap(); + + let listener = UnixListener::bind(&socket_path).unwrap(); + let managed_checkout_for_server = managed_checkout.clone(); + let server = thread::spawn(move || { + let (mut first, _) = listener.accept().unwrap(); + let mut first_line = String::new(); + let mut first_reader = BufReader::new(first.try_clone().unwrap()); + first_reader.read_line(&mut first_line).unwrap(); + let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); + assert_eq!(first_request["method"], "plugin.list"); + first + .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#) + .unwrap(); + first.write_all(b"\n").unwrap(); + first.flush().unwrap(); + + let (mut second, _) = listener.accept().unwrap(); + let mut second_line = String::new(); + let mut second_reader = BufReader::new(second.try_clone().unwrap()); + second_reader.read_line(&mut second_line).unwrap(); + let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap(); + assert_eq!(second_request["method"], "plugin.link"); + writeln!( + second, + "{}", + serde_json::json!({ + "id": "cli:plugin", + "result": { + "type": "plugin_linked", + "plugin": { + "plugin_id": "example.worktree-bootstrap", + "name": "Worktree Bootstrap", + "version": "0.1.0", + "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), + "plugin_root": managed_checkout_for_server.display().to_string(), + "enabled": true, + "source": {"kind": "local"} + } + } + }) + ) + .unwrap(); + second.flush().unwrap(); + + let (mut third, _) = listener.accept().unwrap(); + let mut third_line = String::new(); + let mut third_reader = BufReader::new(third.try_clone().unwrap()); + third_reader.read_line(&mut third_line).unwrap(); + let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap(); + assert_eq!(third_request["method"], "plugin.unlink"); + assert_eq!( + third_request["params"]["plugin_id"], + "example.worktree-bootstrap" + ); + third + .write_all( + br#"{"id":"cli:plugin","result":{"type":"plugin_unlinked","plugin_id":"example.worktree-bootstrap","removed":true}}"#, + ) + .unwrap(); + third.write_all(b"\n").unwrap(); + third.flush().unwrap(); + }); + + let install = run_named_cli_with_env_and_socket_override( + &config_home, + &runtime_dir, + &[ + "plugin", + "install", + "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + Some(&socket_path), + ); + assert!( + !install.status.success(), + "install should fail when server drops GitHub source metadata" + ); + server.join().unwrap(); + assert!( + !managed_checkout.exists(), + "new checkout should be removed after incompatible plugin.link response" + ); + + cleanup_test_base(&base); +} + +#[test] +fn plugin_install_keeps_checkout_when_incompatible_server_cleanup_fails() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("fake-herdr.sock"); + let source_repo = base.join("source-repo"); + let plugin_dir = source_repo.join("worktree-bootstrap"); + fs::create_dir_all(&plugin_dir).unwrap(); + create_committed_repo(&source_repo); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.1.0" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "bootstrap" +title = "Bootstrap" +command = ["sh", "-c", "echo install"] +"#, + ) + .unwrap(); + run_git( + &source_repo, + &["add", "worktree-bootstrap/herdr-plugin.toml"], + ); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); + + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + let managed_checkout = config_home + .join("herdr-dev") + .join("plugins") + .join("github") + .join("example.worktree-bootstrap"); + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", + source_repo.display() + ), + ) + .unwrap(); + + let listener = UnixListener::bind(&socket_path).unwrap(); + let managed_checkout_for_server = managed_checkout.clone(); + let server = thread::spawn(move || { + let (mut first, _) = listener.accept().unwrap(); + let mut first_line = String::new(); + let mut first_reader = BufReader::new(first.try_clone().unwrap()); + first_reader.read_line(&mut first_line).unwrap(); + first + .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#) + .unwrap(); + first.write_all(b"\n").unwrap(); + first.flush().unwrap(); + + let (mut second, _) = listener.accept().unwrap(); + let mut second_line = String::new(); + let mut second_reader = BufReader::new(second.try_clone().unwrap()); + second_reader.read_line(&mut second_line).unwrap(); + writeln!( + second, + "{}", + serde_json::json!({ + "id": "cli:plugin", + "result": { + "type": "plugin_linked", + "plugin": { + "plugin_id": "example.worktree-bootstrap", + "name": "Worktree Bootstrap", + "version": "0.1.0", + "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), + "plugin_root": managed_checkout_for_server.display().to_string(), + "enabled": true, + "source": {"kind": "local"} + } + } + }) + ) + .unwrap(); + second.flush().unwrap(); + + let (mut third, _) = listener.accept().unwrap(); + let mut third_line = String::new(); + let mut third_reader = BufReader::new(third.try_clone().unwrap()); + third_reader.read_line(&mut third_line).unwrap(); + let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap(); + assert_eq!(third_request["method"], "plugin.unlink"); + third + .write_all( + br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced unlink failure"}}"#, + ) + .unwrap(); + third.write_all(b"\n").unwrap(); + third.flush().unwrap(); + }); + + let install = run_named_cli_with_env_and_socket_override( + &config_home, + &runtime_dir, + &[ + "plugin", + "install", + "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + Some(&socket_path), + ); + assert!( + !install.status.success(), + "install should fail when source metadata is dropped and cleanup fails" + ); + server.join().unwrap(); + assert!( + managed_checkout.exists(), + "checkout should stay when server cleanup fails" + ); + + cleanup_test_base(&base); +} + #[test] fn wait_agent_status_exits_immediately_when_status_already_matches() { let base = unique_test_dir();