From fbd20ad6e488fb3b9efc1612fdffee6e672976c1 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 14 Jun 2026 02:43:45 +0300 Subject: 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. --- tests/cli_wrapper.rs | 972 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 972 insertions(+) (limited to 'tests/cli_wrapper.rs') 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 @@ -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 { @@ -2740,6 +2762,956 @@ fn wait_agent_status_exits_when_idle_status_matches() { cleanup_spawned_herdr(herdr, base); } +#[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(); -- cgit v1.2.3