diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 9f61d02d..2c32b77c 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -450,13 +450,14 @@ Install, list, and remove plugins: ```bash herdr plugin install /[/subdir...] [--ref REF] [--yes] +herdr plugin update [...] [--yes] herdr plugin list [--plugin ID] [--json] herdr plugin uninstall herdr plugin enable herdr plugin disable ``` -`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. +`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. `plugin update` refreshes every GitHub-managed plugin when no targets are given, or only the listed plugin ids or GitHub sources. It preserves each install's requested ref, skips unchanged commits and ignores locally linked plugins during bulk updates. 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. Plugin installation and enabled state are global to the current user. A plugin installed, linked, enabled, or disabled through one Herdr session is immediately available with the same state in every session. diff --git a/docs/next/website/src/content/docs/plugins.mdx b/docs/next/website/src/content/docs/plugins.mdx index 4085e242..120f3362 100644 --- a/docs/next/website/src/content/docs/plugins.mdx +++ b/docs/next/website/src/content/docs/plugins.mdx @@ -172,6 +172,7 @@ Install an example plugin: ```bash herdr plugin install ogulcancelik/herdr-plugin-examples/agent-telegram-notify +herdr plugin update herdr plugin config-dir examples.agent-telegram-notify herdr plugin list herdr plugin action list --plugin examples.agent-telegram-notify @@ -193,6 +194,10 @@ herdr plugin log list --plugin example.layout 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. +`plugin update` refreshes every GitHub-managed plugin when called without a +target, or only the listed plugin ids or GitHub sources. It refetches the ref +recorded during install, skips unchanged commits, and ignores locally linked +plugins during bulk updates. Use `--yes` for noninteractive updates. Installed and linked plugins, including their enabled state, are global to the current user and available in every Herdr session. Both `plugin install` and `plugin link` can register plugins while no Herdr server is running. Plugins @@ -207,8 +212,7 @@ directory for setup docs and shell scripts. installs it also removes the managed checkout, and it accepts either the plugin id or the same `owner/repo[/subdir...]` shorthand used by install. `plugin unlink ` only unregisters a plugin and leaves files alone, which is -useful for local development. There is no separate `plugin update` in v1; -reinstall from GitHub to refresh a managed plugin. +useful for local development. The example cookbook repo is `ogulcancelik/herdr-plugin-examples`. It contains separate example plugins in subdirectories, including `agent-telegram-notify`, diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index 35dbee2f..cfb5ef57 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -10,7 +10,7 @@ use crate::api::schema::{ PluginActionListParams, PluginLinkParams, PluginListParams, PluginLogListParams, PluginManifestAction, PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo, PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, - PluginUnlinkParams, ResponseResult, + PluginSourceKind, PluginUnlinkParams, ResponseResult, }; use crate::app::App; pub(super) use manifest::normalize_plugin_id; @@ -66,6 +66,10 @@ impl App { } pub(super) fn handle_plugin_link(&mut self, id: String, params: PluginLinkParams) -> String { + let preserve_enabled = params + .source + .as_ref() + .is_some_and(|source| source.kind == PluginSourceKind::Github); let mut plugin = match load_plugin_manifest(¶ms.path, params.enabled) { Ok(plugin) => plugin, Err((code, message)) => return encode_error(id, code, message), @@ -80,6 +84,11 @@ impl App { return encode_error(id, "plugin_user_dir_create_failed", err.to_string()); } if let Err(err) = self.update_installed_plugins(|plugins| { + if preserve_enabled { + if let Some(existing) = plugins.get(&plugin.plugin_id) { + plugin.enabled = existing.enabled; + } + } plugins.insert(plugin.plugin_id.clone(), plugin.clone()); }) { return encode_error(id, "plugin_registry_save_failed", err.to_string()); diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index a2c1e6c3..33f2ea6b 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -24,6 +24,7 @@ pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result { match subcommand { "install" => plugin_install(&args[1..]), + "update" => plugin_update(&args[1..]), "uninstall" => plugin_uninstall(&args[1..]), "link" => plugin_link(&args[1..]), "list" => plugin_list(&args[1..]), @@ -190,31 +191,170 @@ fn plugin_install(args: &[String]) -> std::io::Result { return Ok(2); } - let temp_root = create_plugin_temp_dir("install")?; + install_github_plugin(source, requested_ref, yes, true, None) +} + +fn plugin_update(args: &[String]) -> std::io::Result { + let mut targets = Vec::new(); + let mut yes = false; + for arg in args { + match arg.as_str() { + "--yes" | "-y" => yes = true, + other if other.starts_with('-') => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + _ => targets.push(arg.as_str()), + } + } + + let installed = match live_installed_plugins() { + Ok(plugins) => plugins, + Err(err) if is_connection_error(&err) => crate::persist::plugin_registry::try_load()?, + Err(err) => return Err(err), + }; + let plugins = if targets.is_empty() { + installed + .into_iter() + .filter(|plugin| plugin.source.kind == PluginSourceKind::Github) + .collect::>() + } else { + let mut plugins: Vec = Vec::new(); + for target in targets { + let plugin = if target.contains('/') { + let source = match GithubPluginSource::parse(target) { + Ok(source) => source, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; + installed + .iter() + .find(|plugin| plugin_matches_github_source(plugin, &source)) + } else { + installed.iter().find(|plugin| plugin.plugin_id == target) + }; + let Some(plugin) = plugin else { + eprintln!("plugin not installed: {target}"); + return Ok(1); + }; + if plugin.source.kind != PluginSourceKind::Github { + eprintln!("plugin is locally linked and cannot be updated: {target}"); + return Ok(1); + } + if !plugins + .iter() + .any(|selected| selected.plugin_id == plugin.plugin_id) + { + plugins.push(plugin.clone()); + } + } + plugins + }; + + if plugins.is_empty() { + println!("No GitHub-managed plugins installed."); + return Ok(0); + } + if !yes && !io::stdin().is_terminal() { + eprintln!("plugin update requires --yes when stdin is not interactive"); + return Ok(2); + } + + let mut exit_code = 0; + for plugin in plugins { + let plugin_id = plugin.plugin_id.clone(); + let source = match GithubPluginSource::from_installed(&plugin) { + Ok(source) => source, + Err(err) => { + eprintln!("error updating {plugin_id}: {err}"); + exit_code = 1; + continue; + } + }; + match install_github_plugin( + source, + plugin.source.requested_ref.clone(), + yes, + plugin.enabled, + Some(&plugin), + ) { + Ok(code) => exit_code = exit_code.max(code), + Err(err) => { + eprintln!("error updating {plugin_id}: {err}"); + exit_code = 1; + } + } + } + Ok(exit_code) +} + +fn install_github_plugin( + source: GithubPluginSource, + requested_ref: Option, + yes: bool, + enabled: bool, + updating_plugin: Option<&InstalledPluginInfo>, +) -> std::io::Result { + let updating = updating_plugin.is_some(); + let temp_root = create_plugin_temp_dir(if updating { "update" } else { "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 preview_plugin = load_cli_plugin_manifest(&manifest_root, enabled)?; + if let Some(plugin) = updating_plugin { + if preview_plugin.plugin_id != plugin.plugin_id { + return Err(io::Error::other(format!( + "update source now contains plugin {}, expected {}", + preview_plugin.plugin_id, plugin.plugin_id + ))); + } + } + let final_checkout = crate::plugin_paths::managed_checkout_path(&preview_plugin.plugin_id); + let _checkout_lock = lock_managed_checkout(&preview_plugin.plugin_id)?; let existing = installed_plugin_info(&preview_plugin.plugin_id)?; + if let Some(expected) = updating_plugin { + ensure_update_target_unchanged(expected, existing.as_ref())?; + } ensure_replacement_allowed(&preview_plugin, existing.as_ref())?; + if updating + && existing + .as_ref() + .and_then(|plugin| plugin.source.resolved_commit.as_deref()) + == Some(resolved_commit.as_str()) + { + println!("{} is already up to date.", preview_plugin.plugin_id); + return Ok(0); + } 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"); + print_install_preview(&preview_plugin, &source_info, existing.as_ref(), updating); + let prompt = if updating { + "Update this plugin?" + } else { + "Install this plugin?" + }; + if !yes && !confirm(prompt)? { + eprintln!( + "plugin {} cancelled", + if updating { "update" } else { "install" } + ); return Ok(0); } if let Err(err) = run_plugin_build_commands(&preview_plugin, &manifest_root) { - eprintln!("{err}"); + eprintln!( + "{err}\n\nPlugin was not {}.", + if updating { "updated" } else { "installed" } + ); return Ok(1); } - let post_build_plugin = load_cli_plugin_manifest(&manifest_root, true)?; + let post_build_plugin = load_cli_plugin_manifest(&manifest_root, enabled)?; ensure_manifest_unchanged_after_build(&preview_plugin, &post_build_plugin)?; - let final_checkout = crate::plugin_paths::managed_checkout_path(&preview_plugin.plugin_id); let backup_checkout = temp_root.join("previous-checkout"); let mut backup_moved = false; if final_checkout.exists() { @@ -232,10 +372,14 @@ fn plugin_install(args: &[String]) -> std::io::Result { 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) + let mut plugin = load_cli_plugin_manifest(&final_manifest_root, enabled) .map_err(InstallFailure::Rollback)?; plugin.source = source_info.clone(); - register_installed_plugin(plugin.clone(), source_info.clone())?; + if let Some(expected) = updating_plugin { + persist_updated_plugin(&plugin, expected).map_err(InstallFailure::Rollback)?; + } else { + register_installed_plugin(plugin.clone(), source_info.clone())?; + } Ok::(plugin) })(); let plugin = match install_attempt { @@ -249,7 +393,12 @@ fn plugin_install(args: &[String]) -> std::io::Result { } Err(InstallFailure::KeepCheckout(err)) => return Err(err), }; - println!("Installed {} from {}.", plugin.plugin_id, source.display()); + println!( + "{} {} from {}.", + if updating { "Updated" } else { "Installed" }, + plugin.plugin_id, + source.display() + ); println!( "Config: {}", crate::plugin_paths::plugin_config_dir(&plugin.plugin_id).display() @@ -288,6 +437,11 @@ fn plugin_uninstall(args: &[String]) -> std::io::Result { (target.clone(), existing) } }; + let _checkout_lock = existing + .as_ref() + .filter(|plugin| plugin.source.kind == PluginSourceKind::Github) + .map(|_| lock_managed_checkout(&plugin_id)) + .transpose()?; match super::send_request(&Request { id: "cli:plugin".into(), @@ -763,6 +917,23 @@ impl GithubPluginSource { format!("https://github.com/{}/{}.git", self.owner, self.repo) } + fn from_installed(plugin: &InstalledPluginInfo) -> std::io::Result { + let owner = plugin + .source + .owner + .clone() + .ok_or_else(|| io::Error::other("installed GitHub plugin has no source owner"))?; + let repo = + plugin.source.repo.clone().ok_or_else(|| { + io::Error::other("installed GitHub plugin has no source repository") + })?; + Ok(Self { + owner, + repo, + subdir: plugin.source.subdir.clone(), + }) + } + fn display(&self) -> String { match &self.subdir { Some(subdir) => format!("{}/{}/{}", self.owner, self.repo, subdir), @@ -813,6 +984,23 @@ fn ensure_replacement_allowed( Ok(()) } +fn ensure_update_target_unchanged( + expected: &InstalledPluginInfo, + current: Option<&InstalledPluginInfo>, +) -> std::io::Result<()> { + if current.is_some_and(|current| same_update_target(expected, current)) { + return Ok(()); + } + Err(io::Error::other(format!( + "plugin {} changed while its update was in progress; retry the update", + expected.plugin_id + ))) +} + +fn same_update_target(expected: &InstalledPluginInfo, current: &InstalledPluginInfo) -> bool { + current.manifest_path == expected.manifest_path && current.source == expected.source +} + fn validate_github_segment(label: &str, value: &str) -> Result<(), String> { if value.is_empty() { return Err(format!("GitHub {label} must not be empty")); @@ -911,15 +1099,56 @@ fn load_cli_plugin_manifest(path: &Path, enabled: bool) -> std::io::Result std::io::Result<()> { +fn persist_plugin_offline( + plugin: &InstalledPluginInfo, + preserve_enabled: bool, +) -> std::io::Result<()> { crate::plugin_paths::ensure_plugin_user_dirs(&plugin.plugin_id)?; crate::persist::plugin_registry::update(|plugins| { + let mut plugin = plugin.clone(); + if preserve_enabled { + if let Some(existing) = plugins + .iter() + .find(|entry| entry.plugin_id == plugin.plugin_id) + { + plugin.enabled = existing.enabled; + } + } plugins.retain(|entry| entry.plugin_id != plugin.plugin_id); - plugins.push(plugin.clone()); + plugins.push(plugin); })?; Ok(()) } +fn persist_updated_plugin( + plugin: &InstalledPluginInfo, + expected: &InstalledPluginInfo, +) -> std::io::Result<()> { + let (updated, _) = crate::persist::plugin_registry::update(|plugins| { + let Some(current) = plugins + .iter_mut() + .find(|entry| entry.plugin_id == expected.plugin_id) + else { + return false; + }; + if !same_update_target(expected, current) { + return false; + } + let enabled = current.enabled; + *current = plugin.clone(); + current.enabled = enabled; + true + })?; + if updated { + Ok(()) + } else { + Err(io::Error::other(format!( + "plugin {} changed while its update was in progress; retry the update", + expected.plugin_id + ))) + } +} + fn register_installed_plugin( plugin: InstalledPluginInfo, source: PluginSourceInfo, @@ -975,7 +1204,7 @@ fn register_installed_plugin( Ok(()) } Err(err) if is_connection_error(&err) => { - persist_plugin_offline(&plugin).map_err(InstallFailure::Rollback) + persist_plugin_offline(&plugin, true).map_err(InstallFailure::Rollback) } Err(err) => Err(InstallFailure::Rollback(err)), } @@ -1100,14 +1329,22 @@ fn plugin_by_github_source( fn plugin_matches_github_source(plugin: &InstalledPluginInfo, source: &GithubPluginSource) -> bool { plugin.source.kind == PluginSourceKind::Github - && plugin.source.owner.as_deref() == Some(source.owner.as_str()) - && plugin.source.repo.as_deref() == Some(source.repo.as_str()) + && plugin + .source + .owner + .as_deref() + .is_some_and(|owner| owner.eq_ignore_ascii_case(&source.owner)) + && plugin + .source + .repo + .as_deref() + .is_some_and(|repo| repo.eq_ignore_ascii_case(&source.repo)) && plugin.source.subdir.as_deref() == source.subdir.as_deref() } fn offline_plugin_link_response(params: &PluginLinkParams) -> std::io::Result { let plugin = load_cli_plugin_manifest(Path::new(¶ms.path), params.enabled)?; - persist_plugin_offline(&plugin)?; + persist_plugin_offline(&plugin, false)?; serde_json::to_value(SuccessResponse { id: "cli:plugin".into(), result: ResponseResult::PluginLinked { plugin }, @@ -1212,8 +1449,12 @@ fn print_install_preview( plugin: &InstalledPluginInfo, source: &PluginSourceInfo, existing: Option<&InstalledPluginInfo>, + updating: bool, ) { - eprintln!("Plugin install preview:"); + eprintln!( + "Plugin {} preview:", + if updating { "update" } else { "install" } + ); eprintln!(" id: {}", plugin.plugin_id); eprintln!(" name: {}", plugin.name); eprintln!(" version: {}", plugin.version); @@ -1446,8 +1687,7 @@ impl fmt::Display for PluginBuildFailure { write_output_section(f, "stdout", stdout)?; } } - writeln!(f)?; - write!(f, "Plugin was not installed.") + Ok(()) } } @@ -1574,6 +1814,21 @@ fn create_plugin_temp_dir(label: &str) -> std::io::Result { Ok(path) } +fn lock_managed_checkout(plugin_id: &str) -> std::io::Result { + let lock_path = crate::plugin_paths::managed_checkout_lock_path(plugin_id); + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent)?; + } + let lock = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(lock_path)?; + lock.lock()?; + Ok(lock) +} + fn remove_managed_plugin_files(plugin: &InstalledPluginInfo) -> std::io::Result<()> { if plugin.source.kind != PluginSourceKind::Github { return Ok(()); @@ -1655,6 +1910,7 @@ fn print_plugin_response(method: Method) -> std::io::Result { fn print_plugin_help() { eprintln!("herdr plugin commands:"); eprintln!(" herdr plugin install /[/subdir...] [--ref REF] [--yes]"); + eprintln!(" herdr plugin update [...] [--yes]"); eprintln!(" herdr plugin uninstall "); eprintln!(" herdr plugin link [--disabled]"); eprintln!(" herdr plugin list [--plugin ID] [--json]"); @@ -1812,6 +2068,22 @@ mod tests { assert!(plugin_by_github_source(plugins, &source).is_none()); } + #[test] + fn github_source_lookup_ignores_owner_and_repo_case() { + let source = GithubPluginSource::parse("OGULCANCELIK/HERDR-PLUGIN-EXAMPLES").unwrap(); + let plugins = vec![github_plugin( + "examples.root", + "ogulcancelik", + "herdr-plugin-examples", + None, + )]; + + assert_eq!( + plugin_by_github_source(plugins, &source).unwrap().plugin_id, + "examples.root" + ); + } + #[test] fn github_source_lookup_ignores_local_plugins() { let source = GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples").unwrap(); @@ -1826,6 +2098,24 @@ mod tests { assert!(plugin_by_github_source([plugin], &source).is_none()); } + #[test] + fn update_target_revalidation_allows_state_changes_but_rejects_install_changes() { + let expected = github_plugin("examples.root", "owner", "repo", None); + let mut current = expected.clone(); + current.enabled = false; + assert!(ensure_update_target_unchanged(&expected, Some(¤t)).is_ok()); + + current.source.resolved_commit = Some("newer".to_string()); + assert!(ensure_update_target_unchanged(&expected, Some(¤t)).is_err()); + current = expected.clone(); + current.source = PluginSourceInfo::default(); + assert!(ensure_update_target_unchanged(&expected, Some(¤t)).is_err()); + current = expected.clone(); + current.manifest_path.push_str(".reinstalled"); + assert!(ensure_update_target_unchanged(&expected, Some(¤t)).is_err()); + assert!(ensure_update_target_unchanged(&expected, None).is_err()); + } + #[test] fn cli_user_dir_creation_seeds_legacy_config_before_printing_config_dir() { let plugin_id = unique_plugin_id("legacy-config"); diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 9465dcc3..3337b618 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -791,6 +791,17 @@ fn plugin_command() -> Command { .about("Uninstall a plugin") .arg(required("plugin", "PLUGIN")), ) + .subcommand( + Command::new("update") + .about("Update GitHub-installed plugins") + .arg(Arg::new("plugins").value_name("PLUGIN").num_args(0..)) + .arg( + Arg::new("yes") + .short('y') + .long("yes") + .action(ArgAction::SetTrue), + ), + ) .subcommand( Command::new("link") .about("Link a local plugin") diff --git a/src/plugin_paths.rs b/src/plugin_paths.rs index 62eed4ff..10e7db10 100644 --- a/src/plugin_paths.rs +++ b/src/plugin_paths.rs @@ -12,6 +12,13 @@ pub(crate) fn managed_checkout_path(plugin_id: &str) -> PathBuf { .join(crate::api::schema::plugin_managed_path_component(plugin_id)) } +pub(crate) fn managed_checkout_lock_path(plugin_id: &str) -> PathBuf { + managed_plugins_dir().join(".locks").join(format!( + ".{}.lock", + crate::api::schema::plugin_managed_path_component(plugin_id) + )) +} + pub(crate) fn plugin_config_dir(plugin_id: &str) -> PathBuf { managed_plugins_dir() .join("config") diff --git a/tests/cli/plugins.rs b/tests/cli/plugins.rs index 6726c243..2cd15a12 100644 --- a/tests/cli/plugins.rs +++ b/tests/cli/plugins.rs @@ -81,6 +81,7 @@ fn named_sessions_share_live_plugin_registry() { &runtime_dir, &["--session", "beta", "plugin", "disable", "example.first"], ); + let disabled = run_named_cli( &config_home, &runtime_dir, @@ -247,6 +248,348 @@ platforms = ["linux", "macos", "windows"] cleanup_test_base(&base); } +#[test] +fn plugin_update_refreshes_selected_then_all_github_plugins() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let source_repo = base.join("source-repo"); + create_committed_repo(&source_repo); + run_git(&source_repo, &["checkout", "-b", "plugin-updates"]); + + for (subdir, id) in [("first", "example.first"), ("second", "example.second")] { + let plugin_dir = source_repo.join(subdir); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + format!( + "id = \"{id}\"\nname = \"{id}\"\nversion = \"0.1.0\"\nmin_herdr_version = \"0.6.10\"\n" + ), + ) + .unwrap(); + } + run_git(&source_repo, &["add", "."]); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugins"]); + + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/example/plugins.git\n", + source_repo.display() + ), + ) + .unwrap(); + for subdir in ["first", "second"] { + let source = format!("example/plugins/{subdir}"); + let ref_args = (subdir == "first").then_some(["--ref", "plugin-updates"]); + let mut args = vec!["plugin", "install", source.as_str(), "--yes"]; + if let Some(ref_args) = ref_args { + args.extend(ref_args); + } + let output = run_named_cli_with_env( + &config_home, + &runtime_dir, + &args, + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let server = spawn_named_server(&config_home, &runtime_dir, "updates"); + wait_for_socket( + &named_session_socket(&config_home, "updates"), + Duration::from_secs(5), + ); + run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "updates", "plugin", "disable", "example.first"], + ); + let reinstalled = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "updates", + "plugin", + "install", + "example/plugins/first", + "--ref", + "plugin-updates", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!(reinstalled.status.success()); + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "updates", "plugin", "list", "--json"], + ); + assert_eq!(listed["result"]["plugins"][0]["enabled"], false); + let offline_reinstall = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "offline-update", + "plugin", + "install", + "example/plugins/first", + "--ref", + "plugin-updates", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!(offline_reinstall.status.success()); + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "updates", "plugin", "list", "--json"], + ); + assert_eq!(listed["result"]["plugins"][0]["enabled"], false); + let malformed = run_named_cli( + &config_home, + &runtime_dir, + &[ + "--session", + "updates", + "plugin", + "update", + "example/plugins/..", + "--yes", + ], + ); + assert_eq!(malformed.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&malformed.stderr).contains("invalid plugin subdir segment")); + + for (subdir, id) in [("first", "example.first"), ("second", "example.second")] { + fs::write( + source_repo.join(subdir).join("herdr-plugin.toml"), + format!( + "id = \"{id}\"\nname = \"{id}\"\nversion = \"0.2.0\"\nmin_herdr_version = \"0.6.10\"\n" + ), + ) + .unwrap(); + } + run_git(&source_repo, &["add", "."]); + run_git(&source_repo, &["commit", "--quiet", "-m", "update plugins"]); + + let selected = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "updates", + "plugin", + "update", + "EXAMPLE/PLUGINS/first", + "example.first", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!(selected.status.success()); + assert!(String::from_utf8_lossy(&selected.stdout).contains("Updated example.first")); + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "updates", "plugin", "list", "--json"], + ); + assert_eq!(listed["result"]["plugins"][0]["version"], "0.2.0"); + assert_eq!(listed["result"]["plugins"][0]["enabled"], false); + assert_eq!(listed["result"]["plugins"][1]["version"], "0.1.0"); + + let local_dir = base.join("local-plugin"); + fs::create_dir_all(&local_dir).unwrap(); + fs::write( + local_dir.join("herdr-plugin.toml"), + "id = \"example.local\"\nname = \"Local\"\nversion = \"0.1.0\"\nmin_herdr_version = \"0.6.10\"\n", + ) + .unwrap(); + let linked = run_named_cli( + &config_home, + &runtime_dir, + &[ + "--session", + "updates", + "plugin", + "link", + local_dir.to_str().unwrap(), + ], + ); + assert!(linked.status.success()); + + let all = run_named_cli_with_env( + &config_home, + &runtime_dir, + &["--session", "updates", "plugin", "update", "--yes"], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!(all.status.success()); + let stdout = String::from_utf8_lossy(&all.stdout); + assert!(stdout.contains("example.first is already up to date")); + assert!(stdout.contains("Updated example.second")); + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "updates", "plugin", "list", "--json"], + ); + let plugins = listed["result"]["plugins"].as_array().unwrap(); + for id in ["example.first", "example.second"] { + let plugin = plugins + .iter() + .find(|plugin| plugin["plugin_id"] == id) + .unwrap(); + assert_eq!(plugin["version"], "0.2.0"); + } + assert!(plugins + .iter() + .any(|plugin| plugin["plugin_id"] == "example.local")); + + let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "updates"]); + drop(server); + cleanup_test_base(&base); +} + +#[test] +fn plugin_update_does_not_resurrect_a_plugin_unlinked_during_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("plugin"); + let build_started = base.join("build-started"); + let release_build = base.join("release-build"); + create_committed_repo(&source_repo); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + "id = \"example.race\"\nname = \"Race\"\nversion = \"0.1.0\"\nmin_herdr_version = \"0.6.10\"\n", + ) + .unwrap(); + run_git(&source_repo, &["add", "."]); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); + + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/example/race.git\n", + source_repo.display() + ), + ) + .unwrap(); + let installed = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "race", + "plugin", + "install", + "example/race/plugin", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!(installed.status.success()); + + let server = spawn_named_server(&config_home, &runtime_dir, "race"); + wait_for_socket( + &named_session_socket(&config_home, "race"), + Duration::from_secs(5), + ); + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "race", "plugin", "list", "--json"], + ); + let managed_path = PathBuf::from( + listed["result"]["plugins"][0]["source"]["managed_path"] + .as_str() + .unwrap(), + ); + + fs::write( + plugin_dir.join("herdr-plugin.toml"), + format!( + "id = \"example.race\"\nname = \"Race\"\nversion = \"0.2.0\"\nmin_herdr_version = \"0.6.10\"\n\n[[build]]\ncommand = [\"sh\", \"-c\", \"touch {}; while [ ! -e {} ]; do sleep 0.05; done\"]\n", + build_started.display(), + release_build.display() + ), + ) + .unwrap(); + run_git(&source_repo, &["add", "."]); + run_git(&source_repo, &["commit", "--quiet", "-m", "update plugin"]); + + let mut update = Command::new(env!("CARGO_BIN_EXE_herdr")); + update + .args([ + "--session", + "race", + "plugin", + "update", + "example.race", + "--yes", + ]) + .env("XDG_CONFIG_HOME", &config_home) + .env("XDG_RUNTIME_DIR", &runtime_dir) + .env("GIT_CONFIG_GLOBAL", &git_config) + .env_remove("HERDR_SOCKET_PATH") + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_ENV") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let child = update.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !build_started.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + if !build_started.exists() { + fs::write(&release_build, "release").unwrap(); + let output = child.wait_with_output().unwrap(); + panic!( + "plugin update never reached its build: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let unlinked = run_named_cli( + &config_home, + &runtime_dir, + &["--session", "race", "plugin", "unlink", "example.race"], + ); + assert!(unlinked.status.success()); + fs::write(&release_build, "release").unwrap(); + let updated = child.wait_with_output().unwrap(); + assert!(!updated.status.success()); + assert!(String::from_utf8_lossy(&updated.stderr) + .contains("changed while its update was in progress")); + + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "race", "plugin", "list", "--json"], + ); + assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); + assert!( + fs::read_to_string(managed_path.join("plugin/herdr-plugin.toml")) + .unwrap() + .contains("version = \"0.1.0\"") + ); + + let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "race"]); + drop(server); + cleanup_test_base(&base); +} + #[test] fn plugin_link_list_unlink_cli_smoke_test() { let base = unique_test_dir();