diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-21 17:37:31 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-21 17:37:43 +0300 |
| commit | 1955406eafa37ae3d02d433d043b476e4a9a0bc4 (patch) | |
| tree | 8081633269d4371a9613f98b129e734243e400f0 | |
| parent | 6382bd433a66683d29455409e1dbb16776727bbf (diff) | |
fix: link plugins without a running server
refs #1670
| -rw-r--r-- | docs/next/website/src/content/docs/plugins.mdx | 7 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/socket-api.mdx | 5 | ||||
| -rw-r--r-- | src/cli/plugin.rs | 41 | ||||
| -rw-r--r-- | tests/cli/plugins.rs | 58 |
4 files changed, 96 insertions, 15 deletions
diff --git a/docs/next/website/src/content/docs/plugins.mdx b/docs/next/website/src/content/docs/plugins.mdx index 21ef2c85..1d93bedc 100644 --- a/docs/next/website/src/content/docs/plugins.mdx +++ b/docs/next/website/src/content/docs/plugins.mdx @@ -195,9 +195,10 @@ terminals, runs supported build commands, then stores the checkout under Herdr-managed plugin data and registers it. Use `--yes` for noninteractive installs. Reinstalling a GitHub-managed plugin replaces that managed checkout. Installed and linked plugins, including their enabled state, are global to the -current user and available in every Herdr session. Plugins installed only in a -named session on Herdr 0.7.3 must be installed or linked again. Existing plugin -config and state remain in place. +current user and available in every Herdr session. Both `plugin install` and +`plugin link` can register plugins while no Herdr server is running. Plugins +installed only in a named session on Herdr 0.7.3 must be installed or linked +again. Existing plugin config and state remain in place. Installing over a locally linked plugin is refused; unlink or uninstall the local plugin first. `plugin install` and `plugin link` create the plugin's config and state directories, and `plugin config-dir <id>` prints the config diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 68045ecb..db62db7f 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -453,8 +453,9 @@ argv pane creation are not part of v1. Installed and linked plugins persist across restarts. Herdr writes a `plugins.json` registry file alongside `session.json` on `plugin.link`, `plugin.unlink`, `plugin.enable`, and `plugin.disable`. 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 +`herdr plugin install` and `herdr plugin link` CLIs also write 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. diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index bd162e40..6bee42be 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -69,11 +69,20 @@ fn plugin_link(args: &[String]) -> std::io::Result<i32> { } } } - print_plugin_response(Method::PluginLink(PluginLinkParams { + let params = PluginLinkParams { path, enabled, source: None, - })) + }; + let response = match super::send_request(&Request { + id: "cli:plugin".into(), + method: Method::PluginLink(params.clone()), + }) { + Ok(response) => response, + Err(err) if is_connection_error(&err) => offline_plugin_link_response(¶ms)?, + Err(err) => return Err(err), + }; + super::print_response(&response) } fn plugin_config_dir_command(args: &[String]) -> std::io::Result<i32> { @@ -893,6 +902,15 @@ fn load_cli_plugin_manifest(path: &Path, enabled: bool) -> std::io::Result<Insta .map_err(|(_, message)| std::io::Error::other(message)) } +fn persist_plugin_offline(plugin: &InstalledPluginInfo) -> std::io::Result<()> { + crate::plugin_paths::ensure_plugin_user_dirs(&plugin.plugin_id)?; + crate::persist::plugin_registry::update(|plugins| { + plugins.retain(|entry| entry.plugin_id != plugin.plugin_id); + plugins.push(plugin.clone()); + })?; + Ok(()) +} + fn register_installed_plugin( plugin: InstalledPluginInfo, source: PluginSourceInfo, @@ -948,14 +966,7 @@ fn register_installed_plugin( Ok(()) } Err(err) if is_connection_error(&err) => { - crate::plugin_paths::ensure_plugin_user_dirs(&plugin.plugin_id) - .map_err(InstallFailure::Rollback)?; - crate::persist::plugin_registry::update(|plugins| { - plugins.retain(|entry| entry.plugin_id != plugin.plugin_id); - plugins.push(plugin); - }) - .map(|_| ()) - .map_err(InstallFailure::Rollback) + persist_plugin_offline(&plugin).map_err(InstallFailure::Rollback) } Err(err) => Err(InstallFailure::Rollback(err)), } @@ -1085,6 +1096,16 @@ fn plugin_matches_github_source(plugin: &InstalledPluginInfo, source: &GithubPlu && plugin.source.subdir.as_deref() == source.subdir.as_deref() } +fn offline_plugin_link_response(params: &PluginLinkParams) -> std::io::Result<serde_json::Value> { + let plugin = load_cli_plugin_manifest(Path::new(¶ms.path), params.enabled)?; + persist_plugin_offline(&plugin)?; + serde_json::to_value(SuccessResponse { + id: "cli:plugin".into(), + result: ResponseResult::PluginLinked { plugin }, + }) + .map_err(std::io::Error::other) +} + fn offline_plugin_list_response(params: &PluginListParams) -> std::io::Result<serde_json::Value> { let entries = crate::persist::plugin_registry::load(); let mut plugins = diff --git a/tests/cli/plugins.rs b/tests/cli/plugins.rs index f76ae0a0..6726c243 100644 --- a/tests/cli/plugins.rs +++ b/tests/cli/plugins.rs @@ -111,6 +111,64 @@ fn named_sessions_share_live_plugin_registry() { } #[test] +fn plugin_link_works_offline_and_is_global() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let state_home = base.join("state"); + let plugin_dir = base.join("plugins").join("offline"); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.offline" +name = "Offline Plugin" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos", "windows"] +"#, + ) + .unwrap(); + + let link_args = [ + "--session", + "offline", + "plugin", + "link", + plugin_dir.to_str().unwrap(), + "--disabled", + ]; + let linked = parse_cli_json_output( + &link_args, + run_named_cli_with_env( + &config_home, + &runtime_dir, + &link_args, + &[("XDG_STATE_HOME", &state_home)], + ), + ); + assert_eq!(linked["result"]["type"], "plugin_linked"); + assert_eq!(linked["result"]["plugin"]["plugin_id"], "example.offline"); + assert_eq!(linked["result"]["plugin"]["enabled"], false); + assert_eq!(linked["result"]["plugin"]["source"]["kind"], "local"); + assert!(!named_session_socket(&config_home, "offline").exists()); + + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "other", "plugin", "list", "--json"], + ); + assert_eq!(listed["result"]["plugins"].as_array().unwrap().len(), 1); + assert_eq!( + listed["result"]["plugins"][0]["plugin_id"], + "example.offline" + ); + assert_eq!(listed["result"]["plugins"][0]["enabled"], false); + + cleanup_test_base(&base); +} + +#[test] fn plugin_install_through_named_server_is_global() { let base = unique_test_dir(); let config_home = base.join("config"); |
