aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-15 00:49:58 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-15 00:49:58 +0300
commitc91348e68b9e438159ffecc3e26bc6f971e0fa2a (patch)
tree30d2abbe5c001d044e66436effa5470783571d77
parent3015ce0f9d120df7583f838d1a47cb469fe5a727 (diff)
fix: require plugin min herdr version
-rw-r--r--docs/next/CHANGELOG.md2
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx2
-rw-r--r--docs/next/website/src/content/docs/plugins.mdx10
-rw-r--r--docs/next/website/src/content/docs/socket-api.mdx4
-rw-r--r--src/api/schema/plugins.rs2
-rw-r--r--src/api/schema/tests.rs1
-rw-r--r--src/app/api/plugins/manifest.rs35
-rw-r--r--src/app/api/plugins/mod.rs74
-rw-r--r--src/cli/plugin.rs1
-rw-r--r--src/persist/plugin_registry.rs2
-rw-r--r--tests/cli_wrapper.rs19
-rw-r--r--tests/fixtures/plugin-smoke/herdr-plugin.toml1
12 files changed, 148 insertions, 5 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 57251781..267a033e 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -23,6 +23,8 @@
- Plugin runtime config directories now use stable, readable plugin-id paths
instead of checkout hashes; existing legacy config directories are copied into
the new location when first seen.
+- Plugin manifests must now declare `min_herdr_version`; install and link fail
+ when a plugin requires a newer Herdr binary.
- Public workspace, tab, and pane ids are now short stable handles such as `w1`, `w1:t1`, and `w1:p1`; closed tab and pane ids no longer retarget later resources. (#569)
### Fixed
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index 106823b3..35255393 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -282,7 +282,7 @@ 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, runs supported manifest build commands, and stores GitHub installs in a Herdr-managed directory. Use `--yes` for noninteractive installs. Reinstalling a GitHub-managed plugin replaces that managed checkout. Installing over a locally linked plugin is refused. `plugin list` is human-readable by default; pass `--json` for the raw API response.
+`plugin install` accepts GitHub shorthand only, such as `ogulcancelik/herdr-plugin-examples/worktree-bootstrap`. It uses `git`, shows a trust preview in interactive terminals, runs supported manifest build commands, and stores GitHub installs in a Herdr-managed directory. Use `--yes` for noninteractive installs. Reinstalling a GitHub-managed plugin replaces that managed checkout. Installing over a locally linked plugin is refused. Plugin manifests must declare `min_herdr_version`; install and link fail when the plugin requires a newer Herdr binary. `plugin list` is human-readable by default; pass `--json` for the raw API response.
Local development:
diff --git a/docs/next/website/src/content/docs/plugins.mdx b/docs/next/website/src/content/docs/plugins.mdx
index cf5b4ff2..e892fd01 100644
--- a/docs/next/website/src/content/docs/plugins.mdx
+++ b/docs/next/website/src/content/docs/plugins.mdx
@@ -42,6 +42,7 @@ Herdr can run.
id = "example.layout"
name = "Layout"
version = "0.1.0"
+min_herdr_version = "0.6.10"
description = "Apply project layouts"
platforms = ["linux", "macos", "windows"]
@@ -75,8 +76,12 @@ pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$"
action = "apply"
```
-Top-level `id`, `name`, and `version` are required. `description` is optional.
-Plugin ids may use ASCII letters, digits, dot, colon, underscore, and hyphen.
+Top-level `id`, `name`, `version`, and `min_herdr_version` are required.
+Set `min_herdr_version` to the oldest Herdr version that supports the plugin
+APIs, event names, and manifest fields your plugin uses. Herdr refuses to link
+or install a plugin when its minimum version is newer than the current binary.
+`description` is optional. Plugin ids may use ASCII letters, digits, dot,
+colon, underscore, and hyphen.
Action ids, pane ids, and link handler ids are local ids inside the plugin.
They may use ASCII letters, digits, colon, underscore, and hyphen, but not
@@ -107,6 +112,7 @@ my-plugin/
id = "example.workspace-tools"
name = "Workspace Tools"
version = "0.1.0"
+min_herdr_version = "0.6.10"
description = "Small workspace helpers"
platforms = ["linux", "macos", "windows"]
diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx
index 2bfe41df..0331d560 100644
--- a/docs/next/website/src/content/docs/socket-api.mdx
+++ b/docs/next/website/src/content/docs/socket-api.mdx
@@ -323,6 +323,7 @@ manifest path. The manifest shape is:
id = "example.worktree-bootstrap"
name = "Worktree Bootstrap"
version = "0.1.0"
+min_herdr_version = "0.6.10"
description = "Prepare new worktrees"
platforms = ["linux", "macos", "windows"]
@@ -352,6 +353,9 @@ pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$"
action = "bootstrap"
```
+`min_herdr_version` is required. The server refuses to link a plugin when the
+field is missing, invalid, or newer than the running Herdr binary.
+
Declare `platforms` at the top level with the OS identifiers (`linux`, `macos`,
`windows`) your plugin supports. Omitting `platforms` is allowed for local
development — `plugin.link` succeeds but the response includes a warning.
diff --git a/src/api/schema/plugins.rs b/src/api/schema/plugins.rs
index 907f243b..c809be58 100644
--- a/src/api/schema/plugins.rs
+++ b/src/api/schema/plugins.rs
@@ -37,6 +37,8 @@ pub struct InstalledPluginInfo {
pub plugin_id: String,
pub name: String,
pub version: String,
+ #[serde(default)]
+ pub min_herdr_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub manifest_path: String,
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
index 05ac9291..3a3684e5 100644
--- a/src/api/schema/tests.rs
+++ b/src/api/schema/tests.rs
@@ -566,6 +566,7 @@ fn plugin_link_list_unlink_round_trip() {
plugin_id: "example.worktree-bootstrap".into(),
name: "Worktree Bootstrap".into(),
version: "0.1.0".into(),
+ min_herdr_version: crate::build_info::BASE_VERSION.into(),
description: Some("Prepare new worktrees".into()),
manifest_path: "/plugins/worktree-bootstrap/herdr-plugin.toml".into(),
plugin_root: "/plugins/worktree-bootstrap".into(),
diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs
index 92799aae..92ab4754 100644
--- a/src/app/api/plugins/manifest.rs
+++ b/src/app/api/plugins/manifest.rs
@@ -13,6 +13,8 @@ struct RawPluginManifest {
name: String,
version: String,
#[serde(default)]
+ min_herdr_version: Option<String>,
+ #[serde(default)]
description: Option<String>,
#[serde(default)]
platforms: Option<Vec<RawPlatform>>,
@@ -133,6 +135,7 @@ pub(crate) fn load_plugin_manifest(
"invalid_plugin_version",
"plugin version is required",
)?;
+ let min_herdr_version = validate_min_herdr_version(raw.min_herdr_version.as_deref())?;
let description = raw
.description
.map(|description| description.trim().to_string())
@@ -180,6 +183,7 @@ pub(crate) fn load_plugin_manifest(
plugin_id,
name,
version,
+ min_herdr_version,
description,
manifest_path: manifest_path.display().to_string(),
plugin_root: plugin_root.display().to_string(),
@@ -195,6 +199,37 @@ pub(crate) fn load_plugin_manifest(
})
}
+fn validate_min_herdr_version(value: Option<&str>) -> Result<String, (&'static str, String)> {
+ let Some(value) = value else {
+ return Err((
+ "invalid_plugin_min_herdr_version",
+ "plugin min_herdr_version is required".to_string(),
+ ));
+ };
+ let value = non_empty_trimmed(
+ value,
+ "invalid_plugin_min_herdr_version",
+ "plugin min_herdr_version is required",
+ )?;
+ let required = crate::update::Version::parse(&value).ok_or_else(|| {
+ (
+ "invalid_plugin_min_herdr_version",
+ format!(
+ "plugin min_herdr_version must be a semantic version like {}",
+ crate::build_info::BASE_VERSION
+ ),
+ )
+ })?;
+ let current = crate::update::Version::current();
+ if required > current {
+ return Err((
+ "plugin_requires_newer_herdr",
+ format!("plugin requires Herdr {required} or newer; current Herdr is {current}"),
+ ));
+ }
+ Ok(required.to_string())
+}
+
fn normalize_manifest_build(
build: RawPluginManifestBuild,
) -> Result<PluginManifestBuild, (&'static str, String)> {
diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs
index 89f7a23d..1938589c 100644
--- a/src/app/api/plugins/mod.rs
+++ b/src/app/api/plugins/mod.rs
@@ -704,6 +704,7 @@ mod tests {
id = "example.worktree-bootstrap"
name = "Worktree Bootstrap"
version = "0.1.0"
+min_herdr_version = "0.6.10"
description = "Prepare new worktrees"
platforms = ["linux", "macos", "windows"]
@@ -772,6 +773,7 @@ action = "bootstrap"
id = "example.config-dirs"
name = "Config Dirs"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
"#,
);
@@ -806,6 +808,7 @@ platforms = ["linux", "macos", "windows"]
id = "example.legacy-config"
name = "Legacy Config"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
"#,
);
@@ -925,6 +928,56 @@ platforms = ["linux", "macos", "windows"]
}
#[test]
+ fn link_rejects_invalid_min_herdr_versions() {
+ let cases = [
+ (
+ "plugin-missing-min-herdr",
+ r#"
+id = "example.missing-min-herdr"
+name = "Missing Min Herdr"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+"#,
+ "invalid_plugin_min_herdr_version",
+ ),
+ (
+ "plugin-invalid-min-herdr",
+ r#"
+id = "example.invalid-min-herdr"
+name = "Invalid Min Herdr"
+version = "0.1.0"
+min_herdr_version = "soon"
+platforms = ["linux", "macos", "windows"]
+"#,
+ "invalid_plugin_min_herdr_version",
+ ),
+ (
+ "plugin-future-min-herdr",
+ r#"
+id = "example.future-min-herdr"
+name = "Future Min Herdr"
+version = "0.1.0"
+min_herdr_version = "999.0.0"
+platforms = ["linux", "macos", "windows"]
+"#,
+ "plugin_requires_newer_herdr",
+ ),
+ ];
+
+ for (name, manifest, expected_code) in cases {
+ let root = unique_temp_path(name);
+ write_manifest_content(&root, manifest);
+
+ let result = load_plugin_manifest(&root.display().to_string(), true);
+ assert!(
+ matches!(result, Err((code, _)) if code == expected_code),
+ "{name}: expected {expected_code}, got {result:?}"
+ );
+ let _ = std::fs::remove_dir_all(root);
+ }
+ }
+
+ #[test]
fn link_rejects_duplicate_action_ids() {
let root = unique_temp_path("plugin-duplicate-action");
write_manifest_content(
@@ -933,6 +986,7 @@ platforms = ["linux", "macos", "windows"]
id = "example.duplicate"
name = "Duplicate"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[actions]]
@@ -961,6 +1015,7 @@ command = ["echo", "b"]
id = "example.dotted-action"
name = "Dotted Action"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[actions]]
@@ -984,6 +1039,7 @@ command = ["echo", "build"]
id = "example.duplicate-pane"
name = "Duplicate Pane"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[panes]]
@@ -1099,6 +1155,7 @@ command = ["echo", "b"]
id = "example.pane"
name = "Pane Plugin"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos"]
[[panes]]
@@ -1205,6 +1262,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \"$PWD\" \"$HERDR_
id = "example.path-env"
name = "Path Env"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos"]
[[panes]]
@@ -1310,6 +1368,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PL
id = "example.tab"
name = "Tab Plugin"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos"]
[[panes]]
@@ -1514,6 +1573,7 @@ command = ["sh", "-c", "sleep 1"]
id = "example.runner"
name = "Runner"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos"]
[[actions]]
@@ -1580,6 +1640,7 @@ command = ["sh", "-c", "printf '%s' \"$HERDR_PLUGIN_ACTION_ID\""]
id = "example.action-paths"
name = "Action Paths"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos"]
[[actions]]
@@ -1702,6 +1763,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG
id = "example.event-context"
name = "Event Context"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos"]
[[events]]
@@ -1851,6 +1913,7 @@ command = ["sh", "-c", "printf '%s' \"$HERDR_PLUGIN_CONTEXT_JSON\" > {}"]
id = "example.links"
name = "Links"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos"]
[[actions]]
@@ -1918,6 +1981,7 @@ action = "open"
id = "example.link-order"
name = "Link Order"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[actions]]
@@ -1964,6 +2028,7 @@ action = "generic"
id = "example.bad-links"
name = "Bad Links"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[actions]]
@@ -2006,6 +2071,7 @@ action = "open"
id = "example.bad-link-action"
name = "Bad Link Action"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[actions]]
@@ -2079,6 +2145,7 @@ action = "missing"
id = "example.context"
name = "Context"
version = "0.1.0"
+min_herdr_version = "0.6.10"
[[actions]]
id = "show"
@@ -2175,6 +2242,7 @@ command = ["show-ctx"]
id = "example.bad-event"
name = "Bad Event Plugin"
version = "0.1.0"
+min_herdr_version = "0.6.10"
[[events]]
on = "worktree.craeted"
@@ -2458,6 +2526,7 @@ command = ["sh", "-c", "echo ok"]
id = "example.platforms"
name = "Platforms"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos"]
[[actions]]
@@ -2540,6 +2609,7 @@ command = ["run.bat"]
id = "example.reject"
name = "Reject"
version = "0.1.0"
+min_herdr_version = "0.6.10"
{excluded_platforms}
[[actions]]
@@ -2604,6 +2674,7 @@ command = ["act"]
id = "example.override"
name = "Override"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[actions]]
@@ -2654,6 +2725,7 @@ command = ["act"]
id = "example.nodecl"
name = "No Decl"
version = "0.1.0"
+min_herdr_version = "0.6.10"
[[actions]]
id = "act"
@@ -2711,6 +2783,7 @@ command = ["act"]
id = "example.badplatform"
name = "Bad Platform"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "beos"]
[[actions]]
@@ -2742,6 +2815,7 @@ command = ["act"]
id = "example.platform-rt"
name = "Platform RT"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos"]
[[actions]]
diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs
index ffdcd711..261540c7 100644
--- a/src/cli/plugin.rs
+++ b/src/cli/plugin.rs
@@ -1631,6 +1631,7 @@ mod tests {
plugin_id: id.to_string(),
name: "Test Plugin".to_string(),
version: "0.1.0".to_string(),
+ min_herdr_version: crate::build_info::BASE_VERSION.to_string(),
description: None,
manifest_path: format!("/tmp/{id}/herdr-plugin.toml"),
plugin_root: format!("/tmp/{id}"),
diff --git a/src/persist/plugin_registry.rs b/src/persist/plugin_registry.rs
index b2eaac9f..34796a61 100644
--- a/src/persist/plugin_registry.rs
+++ b/src/persist/plugin_registry.rs
@@ -116,6 +116,7 @@ mod tests {
plugin_id: id.to_string(),
name: "Test Plugin".to_string(),
version: "0.1.0".to_string(),
+ min_herdr_version: crate::build_info::BASE_VERSION.to_string(),
description: None,
manifest_path: format!("/tmp/{id}/herdr-plugin.toml"),
plugin_root: format!("/tmp/{id}"),
@@ -199,6 +200,7 @@ mod tests {
plugin_id: "example.reload".to_string(),
name: "Fresh Name".to_string(),
version: "0.2.0".to_string(),
+ min_herdr_version: crate::build_info::BASE_VERSION.to_string(),
description: Some("refreshed".to_string()),
manifest_path: "/tmp/example.reload/herdr-plugin.toml".to_string(),
plugin_root: "/tmp/example.reload".to_string(),
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 95d14860..33cea043 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -2889,6 +2889,7 @@ fn plugin_link_list_unlink_cli_smoke_test() {
id = "example.layout"
name = "Layout"
version = "0.1.0"
+min_herdr_version = "0.6.10"
description = "Apply a preferred Herdr layout"
[[actions]]
@@ -3029,6 +3030,7 @@ fn plugin_install_list_uninstall_offline_cli_smoke_test() {
id = "example.worktree-bootstrap"
name = "Worktree Bootstrap"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[build]]
@@ -3158,6 +3160,7 @@ fn plugin_install_build_failure_does_not_register_or_create_checkout() {
id = "example.build-fail"
name = "Build Fail"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[build]]
@@ -3251,6 +3254,7 @@ fn plugin_install_build_spawn_failure_prints_clean_error() {
id = "example.missing-tool"
name = "Missing Tool"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[build]]
@@ -3339,6 +3343,7 @@ fn plugin_install_rejects_manifest_changed_by_build() {
id = "example.manifest-mutator"
name = "Manifest Mutator"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[build]]
@@ -3357,12 +3362,16 @@ command = ["sh", "-c", "echo reviewed"]
id = "example.manifest-mutator"
name = "Manifest Mutator"
version = "0.1.0"
+min_herdr_version = "0.0.1"
platforms = ["linux", "macos", "windows"]
+[[build]]
+command = ["sh", "mutate.sh"]
+
[[actions]]
id = "run"
-title = "Run changed command"
-command = ["sh", "-c", "echo changed"]
+title = "Run reviewed command"
+command = ["sh", "-c", "echo reviewed"]
EOF
"#,
)
@@ -3439,6 +3448,7 @@ fn plugin_install_restores_previous_checkout_when_registration_fails() {
id = "example.worktree-bootstrap"
name = "Worktree Bootstrap"
version = "0.2.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[actions]]
@@ -3494,6 +3504,7 @@ command = ["sh", "-c", "echo new"]
"plugin_id": "example.worktree-bootstrap",
"name": "Worktree Bootstrap",
"version": "0.1.0",
+ "min_herdr_version": "0.6.10",
"manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
"plugin_root": managed_checkout_for_server.display().to_string(),
"enabled": true,
@@ -3569,6 +3580,7 @@ fn plugin_install_rejects_server_that_drops_source_metadata() {
id = "example.worktree-bootstrap"
name = "Worktree Bootstrap"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[actions]]
@@ -3633,6 +3645,7 @@ command = ["sh", "-c", "echo install"]
"plugin_id": "example.worktree-bootstrap",
"name": "Worktree Bootstrap",
"version": "0.1.0",
+ "min_herdr_version": "0.6.10",
"manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
"plugin_root": managed_checkout_for_server.display().to_string(),
"enabled": true,
@@ -3704,6 +3717,7 @@ fn plugin_install_keeps_checkout_when_incompatible_server_cleanup_fails() {
id = "example.worktree-bootstrap"
name = "Worktree Bootstrap"
version = "0.1.0"
+min_herdr_version = "0.6.10"
platforms = ["linux", "macos", "windows"]
[[actions]]
@@ -3764,6 +3778,7 @@ command = ["sh", "-c", "echo install"]
"plugin_id": "example.worktree-bootstrap",
"name": "Worktree Bootstrap",
"version": "0.1.0",
+ "min_herdr_version": "0.6.10",
"manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(),
"plugin_root": managed_checkout_for_server.display().to_string(),
"enabled": true,
diff --git a/tests/fixtures/plugin-smoke/herdr-plugin.toml b/tests/fixtures/plugin-smoke/herdr-plugin.toml
index 20794c4b..013e1189 100644
--- a/tests/fixtures/plugin-smoke/herdr-plugin.toml
+++ b/tests/fixtures/plugin-smoke/herdr-plugin.toml
@@ -1,6 +1,7 @@
id = "example.smoke"
name = "Smoke Plugin"
version = "0.1.0"
+min_herdr_version = "0.6.10"
description = "Portable plugin smoke fixture"
platforms = ["linux", "macos", "windows"]