mirror of
https://github.com/herdrdev/herdr.git
synced 2026-10-07 16:00:41 +00:00
feat: add github plugin install
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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<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 {
|
||||
|
||||
@@ -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
@@ -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(¶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<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user