aboutsummaryrefslogtreecommitdiff
path: root/tests/cli_wrapper.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 02:43:45 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 03:47:07 +0300
commitfbd20ad6e488fb3b9efc1612fdffee6e672976c1 (patch)
treed21daf93241786f8aa6fde23b41cdb9e15182b79 /tests/cli_wrapper.rs
parentd35c642ef36ee5ac2afe7c1b18ad5a717bc807d8 (diff)
feat: add plugin v1 system
Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs. Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs.
Diffstat (limited to 'tests/cli_wrapper.rs')
-rw-r--r--tests/cli_wrapper.rs972
1 files changed, 972 insertions, 0 deletions
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index da730b80..367c3ece 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -186,6 +186,25 @@ fn run_named_cli_with_socket_override(
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
.args(args)
@@ -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 {
@@ -2741,6 +2763,956 @@ fn wait_agent_status_exits_when_idle_status_matches() {
}
#[test]
+fn plugin_link_list_unlink_cli_smoke_test() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+ let plugin_dir = base.join("plugins").join("layout");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.layout"
+name = "Layout"
+version = "0.1.0"
+description = "Apply a preferred Herdr layout"
+
+[[actions]]
+id = "apply"
+title = "Apply layout"
+contexts = ["workspace"]
+command = ["sh", "-c", "echo layout"]
+
+[[events]]
+on = "worktree.created"
+command = ["sh", "-c", "echo worktree"]
+
+[[panes]]
+id = "board"
+title = "Board"
+placement = "tab"
+command = ["sh", "-c", "sleep 5"]
+"#,
+ )
+ .unwrap();
+
+ let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+ let workspace = run_cli_json(
+ &socket_path,
+ &[
+ "workspace",
+ "create",
+ "--cwd",
+ base.to_str().unwrap(),
+ "--focus",
+ ],
+ );
+ assert_eq!(workspace["result"]["type"], "workspace_created");
+
+ let linked = run_cli_json_in_dir(&socket_path, &["plugin", "link", "plugins/layout"], &base);
+ assert_eq!(linked["result"]["type"], "plugin_linked");
+ assert_eq!(linked["result"]["plugin"]["plugin_id"], "example.layout");
+ assert_eq!(linked["result"]["plugin"]["actions"][0]["id"], "apply");
+ assert_eq!(
+ linked["result"]["plugin"]["events"][0]["on"],
+ "worktree.created"
+ );
+ assert_eq!(linked["result"]["plugin"]["panes"][0]["id"], "board");
+
+ 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"],
+ "example.layout"
+ );
+
+ let invoked = run_cli_json(
+ &socket_path,
+ &[
+ "plugin",
+ "action",
+ "invoke",
+ "apply",
+ "--plugin",
+ "example.layout",
+ ],
+ );
+ assert_eq!(invoked["result"]["type"], "plugin_action_invoked");
+ assert_eq!(invoked["result"]["action"]["action_id"], "apply");
+
+ let logs = run_cli_json(
+ &socket_path,
+ &[
+ "plugin",
+ "log",
+ "list",
+ "--plugin",
+ "example.layout",
+ "--limit",
+ "5",
+ ],
+ );
+ assert_eq!(logs["result"]["type"], "plugin_log_list");
+ assert!(!logs["result"]["logs"].as_array().unwrap().is_empty());
+
+ let pane = run_cli_json(
+ &socket_path,
+ &[
+ "plugin",
+ "pane",
+ "open",
+ "--plugin",
+ "example.layout",
+ "--entrypoint",
+ "board",
+ "--env",
+ "HERDR_ROLE=board",
+ "--no-focus",
+ ],
+ );
+ assert_eq!(pane["result"]["type"], "plugin_pane_opened");
+ assert_eq!(pane["result"]["plugin_pane"]["entrypoint"], "board");
+
+ let missing_plugin_value = run_cli(&socket_path, &["plugin", "list", "--plugin"]);
+ assert_eq!(missing_plugin_value.status.code(), Some(2));
+ assert!(String::from_utf8_lossy(&missing_plugin_value.stderr)
+ .contains("missing value for --plugin"));
+
+ let invalid_limit = run_cli(
+ &socket_path,
+ &["plugin", "log", "list", "--limit", "not-a-number"],
+ );
+ assert_eq!(invalid_limit.status.code(), Some(2));
+ assert!(String::from_utf8_lossy(&invalid_limit.stderr).contains("invalid --limit value"));
+
+ let unlinked = run_cli_json(&socket_path, &["plugin", "unlink", "example.layout"]);
+ assert_eq!(unlinked["result"]["type"], "plugin_unlinked");
+ assert_eq!(unlinked["result"]["removed"], true);
+
+ 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"]
+
+[[build]]
+command = ["sh", "-c", "echo built > built.txt; if [ -n \"$HERDR_SESSION\" ]; then echo \"$HERDR_SESSION\" > leaked-session.txt; fi"]
+
+[[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),
+ ("HERDR_SESSION", Path::new("leaked-session")),
+ ],
+ );
+ 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");
+ assert!(
+ managed_path
+ .join("worktree-bootstrap")
+ .join("built.txt")
+ .exists(),
+ "build artifact should be preserved in managed checkout"
+ );
+ assert!(
+ !managed_path
+ .join("worktree-bootstrap")
+ .join("leaked-session.txt")
+ .exists(),
+ "build command should not inherit HERDR_SESSION"
+ );
+
+ 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_build_failure_does_not_register_or_create_checkout() {
+ 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("build-fail");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.build-fail"
+name = "Build Fail"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["sh", "-c", "echo before-fail && echo failed-build >&2 && exit 7"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["sh", "-c", "echo should-not-install"]
+"#,
+ )
+ .unwrap();
+ run_git(&source_repo, &["add", "build-fail/herdr-plugin.toml"]);
+ run_git(
+ &source_repo,
+ &["commit", "--quiet", "-m", "add failing 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/build-fail",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when build command fails"
+ );
+ assert_eq!(install.status.code(), Some(1));
+ let stderr = String::from_utf8_lossy(&install.stderr);
+ assert!(stderr.contains("error: plugin build failed"), "{stderr}");
+ assert!(stderr.contains(" plugin: example.build-fail"), "{stderr}");
+ assert!(stderr.contains(" build: 1/1"), "{stderr}");
+ assert!(stderr.contains(" cwd: "), "{stderr}");
+ assert!(
+ stderr.contains(" command: sh -c echo before-fail && echo failed-build >&2 && exit 7"),
+ "{stderr}"
+ );
+ assert!(stderr.contains(" status: exit status: 7"), "{stderr}");
+ assert!(stderr.contains("stdout:\nbefore-fail"), "{stderr}");
+ assert!(stderr.contains("stderr:\nfailed-build"), "{stderr}");
+ assert!(stderr.contains("Plugin was not installed."), "{stderr}");
+ assert!(!stderr.contains("Error: Custom"), "{stderr}");
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "plugins", "plugin", "list", "--json"],
+ );
+ assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
+
+ let managed_checkout = config_home
+ .join("herdr-dev")
+ .join("plugins")
+ .join("github")
+ .join("example.build-fail");
+ assert!(
+ !managed_checkout.exists(),
+ "failed build should not create managed checkout"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn plugin_install_build_spawn_failure_prints_clean_error() {
+ 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("missing-tool");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.missing-tool"
+name = "Missing Tool"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["definitely-missing-herdr-build-tool-xyz"]
+
+[[actions]]
+id = "run"
+title = "Run"
+command = ["sh", "-c", "echo should-not-install"]
+"#,
+ )
+ .unwrap();
+ run_git(&source_repo, &["add", "missing-tool/herdr-plugin.toml"]);
+ run_git(
+ &source_repo,
+ &["commit", "--quiet", "-m", "add missing tool 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/missing-tool",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when build command cannot start"
+ );
+ assert_eq!(install.status.code(), Some(1));
+ let stderr = String::from_utf8_lossy(&install.stderr);
+ assert!(stderr.contains("error: plugin build failed"), "{stderr}");
+ assert!(
+ stderr.contains(" plugin: example.missing-tool"),
+ "{stderr}"
+ );
+ assert!(stderr.contains(" build: 1/1"), "{stderr}");
+ assert!(
+ stderr.contains(" command: definitely-missing-herdr-build-tool-xyz"),
+ "{stderr}"
+ );
+ assert!(stderr.contains(" error: failed to start:"), "{stderr}");
+ assert!(stderr.contains("Plugin was not installed."), "{stderr}");
+ assert!(!stderr.contains("Error: Custom"), "{stderr}");
+
+ 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_rejects_manifest_changed_by_build() {
+ 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("manifest-mutator");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ create_committed_repo(&source_repo);
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.manifest-mutator"
+name = "Manifest Mutator"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[build]]
+command = ["sh", "mutate.sh"]
+
+[[actions]]
+id = "run"
+title = "Run reviewed command"
+command = ["sh", "-c", "echo reviewed"]
+"#,
+ )
+ .unwrap();
+ fs::write(
+ plugin_dir.join("mutate.sh"),
+ r#"cat > herdr-plugin.toml <<'EOF'
+id = "example.manifest-mutator"
+name = "Manifest Mutator"
+version = "0.1.0"
+platforms = ["linux", "macos", "windows"]
+
+[[actions]]
+id = "run"
+title = "Run changed command"
+command = ["sh", "-c", "echo changed"]
+EOF
+"#,
+ )
+ .unwrap();
+ run_git(&source_repo, &["add", "manifest-mutator"]);
+ run_git(
+ &source_repo,
+ &["commit", "--quiet", "-m", "add mutating 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/manifest-mutator",
+ "--yes",
+ ],
+ &[("GIT_CONFIG_GLOBAL", &git_config)],
+ );
+ assert!(
+ !install.status.success(),
+ "install should fail when build changes reviewed manifest"
+ );
+ let stderr = String::from_utf8_lossy(&install.stderr);
+ assert!(
+ stderr.contains("plugin build changed herdr-plugin.toml after install preview"),
+ "{stderr}"
+ );
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "plugins", "plugin", "list", "--json"],
+ );
+ assert!(listed["result"]["plugins"].as_array().unwrap().is_empty());
+
+ let managed_checkout = config_home
+ .join("herdr-dev")
+ .join("plugins")
+ .join("github")
+ .join("example.manifest-mutator");
+ assert!(
+ !managed_checkout.exists(),
+ "manifest mutation should not create managed checkout"
+ );
+
+ 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();
let config_home = base.join("config");