feat: update managed plugins from GitHub

refs #3219
This commit is contained in:
Jonathan Liebig
2026-09-20 03:37:58 +02:00
parent d59d0603d5
commit bbcd1049a7
7 changed files with 689 additions and 24 deletions
@@ -450,13 +450,14 @@ Install, list, and remove plugins:
```bash
herdr plugin install <owner>/<repo>[/subdir...] [--ref REF] [--yes]
herdr plugin update [<plugin_id|owner/repo[/subdir...]>...] [--yes]
herdr plugin list [--plugin ID] [--json]
herdr plugin uninstall <plugin_id|owner/repo[/subdir...]>
herdr plugin enable <plugin_id>
herdr plugin disable <plugin_id>
```
`plugin install` accepts GitHub shorthand only, such as `ogulcancelik/herdr-plugin-examples/worktree-bootstrap`. It uses `git`, shows a trust preview in interactive terminals, runs supported manifest build commands, and stores GitHub installs in a Herdr-managed directory. Use `--yes` for noninteractive installs. Reinstalling a GitHub-managed plugin replaces that managed checkout. Installing over a locally linked plugin is refused. Plugin 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.
@@ -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 <id>` 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`,
+10 -1
View File
@@ -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(&params.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());
+310 -20
View File
@@ -24,6 +24,7 @@ pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result<i32> {
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<i32> {
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<i32> {
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::<Vec<_>>()
} else {
let mut plugins: Vec<InstalledPluginInfo> = 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<String>,
yes: bool,
enabled: bool,
updating_plugin: Option<&InstalledPluginInfo>,
) -> std::io::Result<i32> {
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<i32> {
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::<InstalledPluginInfo, InstallFailure>(plugin)
})();
let plugin = match install_attempt {
@@ -249,7 +393,12 @@ fn plugin_install(args: &[String]) -> std::io::Result<i32> {
}
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<i32> {
(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<Self> {
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<Insta
.map_err(|(_, message)| std::io::Error::other(message))
}
fn persist_plugin_offline(plugin: &InstalledPluginInfo) -> 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<serde_json::Value> {
let plugin = load_cli_plugin_manifest(Path::new(&params.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<PathBuf> {
Ok(path)
}
fn lock_managed_checkout(plugin_id: &str) -> std::io::Result<std::fs::File> {
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<i32> {
fn print_plugin_help() {
eprintln!("herdr plugin commands:");
eprintln!(" herdr plugin install <owner>/<repo>[/subdir...] [--ref REF] [--yes]");
eprintln!(" herdr plugin update [<plugin_id|owner/repo[/subdir...]>...] [--yes]");
eprintln!(" herdr plugin uninstall <plugin_id|owner/repo[/subdir...]>");
eprintln!(" herdr plugin link <path> [--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(&current)).is_ok());
current.source.resolved_commit = Some("newer".to_string());
assert!(ensure_update_target_unchanged(&expected, Some(&current)).is_err());
current = expected.clone();
current.source = PluginSourceInfo::default();
assert!(ensure_update_target_unchanged(&expected, Some(&current)).is_err());
current = expected.clone();
current.manifest_path.push_str(".reinstalled");
assert!(ensure_update_target_unchanged(&expected, Some(&current)).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");
+11
View File
@@ -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")
+7
View File
@@ -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")
+343
View File
@@ -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();