feat: add github plugin install

This commit is contained in:
Ogulcan Celik
2026-06-13 16:35:07 +03:00
parent 6b17e783b4
commit d47293e1dc
11 changed files with 1515 additions and 23 deletions
+3
View File
@@ -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 <owner>/<repo>[/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
@@ -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 <owner>/<repo>[/subdir...] [--ref REF] [--yes]
herdr plugin list [--plugin ID] [--json]
herdr plugin uninstall <plugin_id>
herdr plugin enable <plugin_id>
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, 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 <path> [--disabled]
herdr plugin list [--plugin ID]
herdr plugin enable <plugin_id>
herdr plugin disable <plugin_id>
herdr plugin unlink <plugin_id>
```
`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:
+18 -4
View File
@@ -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,
@@ -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
+60
View File
@@ -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<PluginSourceInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -50,12 +52,70 @@ pub struct InstalledPluginInfo {
pub panes: Vec<PluginManifestPane>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub link_handlers: Vec<PluginManifestLinkHandler>,
#[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<String>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subdir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub managed_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed_unix_ms: Option<u64>,
}
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,
+2
View File
@@ -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![],
};
+89 -2
View File
@@ -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(&params.path, params.enabled) {
let mut plugin = match load_plugin_manifest(&params.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<PluginSourceInfo, (&'static str, String)> {
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 {
+7
View File
@@ -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<crate::api::schema::InstalledPluginInfo, (&'static str, String)> {
api::plugins::load_plugin_manifest(path, enabled)
}
/// Full application: AppState + runtime concerns (event channels, async I/O).
#[derive(Debug, Clone)]
pub(crate) struct OverlayPaneState {
+766 -7
View File
@@ -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<i32> {
@@ -14,6 +19,8 @@ pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result<i32> {
};
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<i32> {
}
}
}
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<i32> {
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<i32> {
}
}
}
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(&params)?,
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<i32> {
@@ -94,6 +122,153 @@ fn plugin_unlink(args: &[String]) -> std::io::Result<i32> {
}))
}
fn plugin_install(args: &[String]) -> std::io::Result<i32> {
let Some(source_arg) = args.first() else {
eprintln!("usage: herdr plugin install <owner>/<repo>[/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::<InstalledPluginInfo, InstallFailure>(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<i32> {
let Some(plugin_id) = args.first() else {
eprintln!("usage: herdr plugin uninstall <plugin_id>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr plugin uninstall <plugin_id>");
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<i32> {
let Some(plugin_id) = args.first() else {
eprintln!(
@@ -446,6 +621,545 @@ fn normalize_plugin_path_arg(value: &str) -> std::io::Result<String> {
Ok(absolute.display().to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GithubPluginSource {
owner: String,
repo: String,
subdir: Option<String>,
}
impl GithubPluginSource {
fn parse(value: &str) -> Result<Self, String> {
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::<Vec<_>>();
if parts.len() < 2 {
return Err("usage: herdr plugin install <owner>/<repo>[/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<String>,
resolved_commit: String,
managed_path: Option<String>,
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<const N: usize>(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<const N: usize>(cwd: &Path, args: [&str; N]) -> std::io::Result<String> {
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<InstalledPluginInfo> {
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<Option<InstalledPluginInfo>> {
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<Option<InstalledPluginInfo>> {
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<InstalledPluginInfo> {
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<Option<InstalledPluginInfo>> {
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<serde_json::Value> {
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::<Vec<_>>();
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<i32> {
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<bool> {
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<PathBuf> {
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<i32> {
super::print_response(&super::send_request(&Request {
id: "cli:plugin".into(),
@@ -455,8 +1169,10 @@ fn print_plugin_response(method: Method) -> std::io::Result<i32> {
fn print_plugin_help() {
eprintln!("herdr plugin commands:");
eprintln!(" herdr plugin install <owner>/<repo>[/subdir...] [--ref REF] [--yes]");
eprintln!(" herdr plugin uninstall <plugin_id>");
eprintln!(" herdr plugin link <path> [--disabled]");
eprintln!(" herdr plugin list [--plugin ID]");
eprintln!(" herdr plugin list [--plugin ID] [--json]");
eprintln!(" herdr plugin unlink <plugin_id>");
eprintln!(" herdr plugin enable <plugin_id>");
eprintln!(" herdr plugin disable <plugin_id>");
@@ -477,3 +1193,46 @@ fn print_plugin_pane_help() {
eprintln!(" herdr plugin pane focus <pane_id>");
eprintln!(" herdr plugin pane close <pane_id>");
}
#[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"
);
}
}
}
+18
View File
@@ -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());
}
+529 -2
View File
@@ -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();