use anyhow::{bail, Context, Result};
use console::style;
use std::fs;
use std::path::Path;
use std::process::Command;
use super::{
apply_git_auth, link_commands, load_registry, normalize_source, plugin_bin_dir, plugins_dir,
run_build, save_registry, PluginManifest, PLUGINS_UPDATE_SCHEMA,
};
use crate::trust::parse_source_ref;
pub(crate) fn update_plugins(name: Option<&str>, defaults: bool, json: bool) -> Result<()> {
use super::DEFAULT_PLUGINS;
if defaults && name.is_some() {
bail!("--defaults updates the curated set; do not pass a plugin name alongside it.");
}
let registry = load_registry()?;
let targets: Vec<_> = if defaults {
let is_default = |source: &str| -> bool {
DEFAULT_PLUGINS.iter().any(|d| {
let (base, _) = parse_source_ref(d);
source == base
|| source == *d
|| source == normalize_source(d)
|| source.trim_end_matches(".git").ends_with(base)
})
};
let matched: Vec<_> = registry
.plugins
.iter()
.filter(|p| is_default(&p.source))
.collect();
if matched.is_empty() {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"schema_version": PLUGINS_UPDATE_SCHEMA,
"action": "update",
"scope": "defaults",
"results": [],
"summary": { "total": 0, "updated": 0, "skipped": 0, "failed": 0 },
}))?
);
} else {
println!(
"{} No default plugins are installed. Run {} first.",
style("*").cyan().bold(),
style("fledge plugins install --defaults").cyan()
);
}
return Ok(());
}
if !json {
println!(
"{} Updating {} of {} default plugins...",
style("*").cyan().bold(),
matched.len(),
DEFAULT_PLUGINS.len()
);
}
matched
} else {
match name {
Some(n) => {
let entry = registry
.plugins
.iter()
.find(|p| p.name == n || p.name == format!("fledge-{n}"))
.ok_or_else(|| anyhow::anyhow!("Plugin '{n}' is not installed."))?;
vec![entry]
}
None => {
if registry.plugins.is_empty() {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"schema_version": PLUGINS_UPDATE_SCHEMA,
"action": "update",
"scope": "all",
"results": [],
"summary": { "total": 0, "updated": 0, "skipped": 0, "failed": 0 },
}))?
);
} else {
println!("{} No plugins installed.", style("*").cyan().bold());
}
return Ok(());
}
registry.plugins.iter().collect()
}
}
};
let mut results: Vec<serde_json::Value> = Vec::new();
for entry in &targets {
let plugin_dir = plugins_dir().join(&entry.name);
if !plugin_dir.exists() {
if !json {
println!(
" {} {} — directory missing, reinstall with {}",
style("⚠️").yellow(),
style(&entry.name).yellow(),
style(format!("fledge plugin install {} --force", entry.source)).cyan()
);
}
results.push(serde_json::json!({
"name": entry.name,
"status": "failed",
"detail": "directory missing — reinstall required",
}));
continue;
}
if let Some(ref pinned) = entry.pinned_ref {
let latest = find_latest_tag(&plugin_dir);
match latest {
Some(ref tag) if tag != pinned => {
if defaults {
let sp = if json {
None
} else {
Some(crate::spinner::Spinner::start(&format!(
"Upgrading {} {} → {}:",
&entry.name, pinned, tag
)))
};
let checkout = Command::new("git")
.args(["checkout", tag])
.current_dir(&plugin_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.status()
.with_context(|| format!("checking out {} for {}", tag, entry.name))?;
if let Some(s) = sp {
s.finish();
}
if !checkout.success() {
if !json {
println!(
" {} {} — failed to checkout {}, try:\n {}",
style("⚠️").yellow(),
style(&entry.name).yellow(),
style(tag).dim(),
style(format!(
"fledge plugin install {}@{} --force",
entry.source, tag
))
.cyan()
);
}
results.push(serde_json::json!({
"name": entry.name,
"status": "failed",
"detail": format!("git checkout {tag} failed — reinstall required"),
}));
continue;
}
let result = rebuild_after_fetch(entry, &plugin_dir, Some(tag), json)?;
results.push(result);
} else {
if !json {
println!(
" {} {} — pinned to {}, latest tag is {}. To upgrade:\n {}",
style("*").cyan().bold(),
style(&entry.name).cyan(),
style(pinned).dim(),
style(tag).green(),
style(format!(
"fledge plugin install {}@{} --force",
entry.source, tag
))
.cyan()
);
}
results.push(serde_json::json!({
"name": entry.name,
"status": "skipped",
"detail": format!("pinned to {pinned}, latest tag is {tag} — reinstall to upgrade"),
"pinned_ref": pinned,
"latest_tag": tag,
}));
}
}
_ => {
if !json {
println!(
" {} {} — pinned to {}, already up to date.",
style("✅").green().bold(),
style(&entry.name).green(),
style(pinned).dim()
);
}
results.push(serde_json::json!({
"name": entry.name,
"status": "skipped",
"detail": format!("pinned to {pinned}, already up to date"),
"pinned_ref": pinned,
}));
}
}
continue;
}
let sp = if json {
None
} else {
Some(crate::spinner::Spinner::start(&format!(
"Updating {}:",
&entry.name
)))
};
let mut cmd = Command::new("git");
cmd.args(["pull", "--ff-only"])
.current_dir(&plugin_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
apply_git_auth(&mut cmd);
let status = cmd
.status()
.with_context(|| format!("updating {}", entry.name))?;
if let Some(s) = sp {
s.finish();
}
if !status.success() {
if !json {
println!(
" {} {} — git pull failed, try reinstalling with {}",
style("⚠️").yellow(),
style(&entry.name).yellow(),
style(format!("fledge plugin install {} --force", entry.source)).cyan()
);
}
results.push(serde_json::json!({
"name": entry.name,
"status": "failed",
"detail": "git pull failed — reinstall required",
}));
continue;
}
let result = rebuild_after_fetch(entry, &plugin_dir, None, json)?;
results.push(result);
}
if json {
let total = results.len();
let count = |s: &str| results.iter().filter(|r| r["status"] == s).count();
let summary = serde_json::json!({
"total": total,
"updated": count("updated"),
"skipped": count("skipped"),
"failed": count("failed"),
});
let scope = if defaults {
"defaults"
} else if name.is_some() {
"single"
} else {
"all"
};
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"schema_version": PLUGINS_UPDATE_SCHEMA,
"action": "update",
"scope": scope,
"results": results,
"summary": summary,
}))?
);
}
Ok(())
}
fn rebuild_after_fetch(
entry: &super::PluginEntry,
plugin_dir: &Path,
new_pinned_ref: Option<&str>,
json: bool,
) -> Result<serde_json::Value> {
let manifest_path = plugin_dir.join("plugin.toml");
if !manifest_path.exists() {
return Ok(serde_json::json!({
"name": entry.name,
"status": "updated",
"detail": "no plugin.toml — update applied",
}));
}
let manifest_content = fs::read_to_string(&manifest_path).context("reading plugin.toml")?;
let manifest: PluginManifest =
toml::from_str(&manifest_content).context("parsing plugin.toml")?;
let new_caps = &manifest.capabilities;
let old_caps = entry.capabilities.as_ref();
let added_exec = new_caps.exec && !old_caps.is_some_and(|c| c.exec);
let added_store = new_caps.store && !old_caps.is_some_and(|c| c.store);
let added_metadata = new_caps.metadata && !old_caps.is_some_and(|c| c.metadata);
let has_new_caps = added_exec || added_store || added_metadata;
if has_new_caps {
if !json {
println!(
"\n {} {} v{} requests new capabilities:",
style("!").yellow().bold(),
style(&entry.name).cyan(),
manifest.plugin.version
);
if added_exec {
println!(" {} exec — run shell commands", style("+").yellow());
}
if added_store {
println!(
" {} store — persist data between runs",
style("+").yellow()
);
}
if added_metadata {
println!(
" {} metadata — read project metadata and environment",
style("+").yellow()
);
}
println!();
}
if !crate::utils::is_interactive() {
return Ok(serde_json::json!({
"name": entry.name,
"status": "failed",
"detail": "update adds new capabilities — rerun interactively or reinstall with --force",
}));
}
let confirm = dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt("Grant new capabilities?")
.default(false)
.interact()?;
if !confirm {
return Ok(serde_json::json!({
"name": entry.name,
"status": "skipped",
"detail": "new capabilities declined by user",
}));
}
}
run_build(plugin_dir, &manifest)?;
let bin_dir = plugin_bin_dir();
for old_cmd in &entry.commands {
let old_link = bin_dir.join(format!("fledge-{old_cmd}"));
if old_link.exists() || old_link.is_symlink() {
fs::remove_file(&old_link).ok();
}
}
link_commands(plugin_dir, &bin_dir, &manifest)?;
let granted_caps = if manifest.plugin.protocol.is_some() {
Some(manifest.capabilities.clone())
} else {
entry.capabilities.clone()
};
let new_cmds: Vec<String> = manifest.commands.iter().map(|c| c.name.clone()).collect();
let mut reg = load_registry()?;
if let Some(e) = reg.plugins.iter_mut().find(|p| p.name == entry.name) {
e.version = manifest.plugin.version.clone();
e.commands = new_cmds.clone();
e.capabilities = granted_caps;
if let Some(ref_tag) = new_pinned_ref {
e.pinned_ref = Some(ref_tag.to_string());
}
}
save_registry(®)?;
if !json {
if let Some(ref_tag) = new_pinned_ref {
let old_ref = entry.pinned_ref.as_deref().unwrap_or("?");
println!(
" {} {} {} → {} (v{})",
style("✅").green().bold(),
style(&entry.name).green(),
style(old_ref).dim(),
style(ref_tag).green(),
manifest.plugin.version
);
} else {
println!(
" {} {} → v{}",
style("✅").green().bold(),
style(&entry.name).green(),
manifest.plugin.version
);
}
}
let mut result = serde_json::json!({
"name": entry.name,
"status": "updated",
"version": manifest.plugin.version,
"commands": new_cmds,
});
if let Some(ref_tag) = new_pinned_ref {
result["previous_ref"] = serde_json::json!(entry.pinned_ref);
result["new_ref"] = serde_json::json!(ref_tag);
}
Ok(result)
}
pub(crate) fn find_latest_tag(repo_dir: &Path) -> Option<String> {
let mut cmd = Command::new("git");
cmd.args(["fetch", "--tags"])
.current_dir(repo_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
apply_git_auth(&mut cmd);
cmd.status().ok();
let output = Command::new("git")
.args(["tag", "--sort=-v:refname"])
.current_dir(repo_dir)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.next()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}