use anyhow::{bail, Context, Result};
use console::style;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Deserialize)]
struct PluginManifest {
plugin: PluginMeta,
#[serde(default)]
commands: Vec<PluginCommand>,
#[serde(default)]
hooks: PluginHooks,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PluginMeta {
name: String,
version: String,
description: Option<String>,
author: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PluginCommand {
name: String,
description: Option<String>,
binary: String,
}
#[derive(Debug, Deserialize, Default)]
struct PluginHooks {
build: Option<String>,
post_install: Option<String>,
post_remove: Option<String>,
pre_init: Option<String>,
post_work_start: Option<String>,
pre_pr: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PluginsRegistry {
#[serde(default)]
plugins: Vec<PluginEntry>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PluginEntry {
name: String,
source: String,
version: String,
installed: String,
#[serde(default)]
commands: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pinned_ref: Option<String>,
}
pub struct PluginOptions {
pub action: PluginAction,
pub json: bool,
}
pub enum PluginAction {
Install {
source: String,
force: bool,
},
Remove {
name: String,
},
Update {
name: Option<String>,
},
List,
Search {
query: Option<String>,
author: Option<String>,
limit: usize,
},
Run {
name: String,
args: Vec<String>,
},
}
pub fn run(opts: PluginOptions) -> Result<()> {
match opts.action {
PluginAction::Install { source, force } => install_plugin(&source, force),
PluginAction::Remove { name } => remove_plugin(&name),
PluginAction::Update { name } => update_plugins(name.as_deref()),
PluginAction::List => list_plugins(opts.json),
PluginAction::Search {
query,
author,
limit,
} => search_plugins(query.as_deref(), author.as_deref(), limit, opts.json),
PluginAction::Run { name, args } => run_plugin(&name, &args),
}
}
pub fn resolve_plugin_command(name: &str) -> Option<PathBuf> {
let bin_dir = plugin_bin_dir();
let bin_path = bin_dir.join(format!("fledge-{name}"));
if bin_path.exists() {
return Some(bin_path);
}
which_fledge_plugin(name)
}
pub fn run_lifecycle_hook(event: &str) -> Result<()> {
let registry = load_registry()?;
for entry in ®istry.plugins {
let plugin_dir = plugins_dir().join(&entry.name);
let manifest_path = plugin_dir.join("plugin.toml");
if !manifest_path.exists() {
continue;
}
let content = match fs::read_to_string(&manifest_path) {
Ok(c) => c,
Err(_) => continue,
};
let manifest: PluginManifest = match toml::from_str(&content) {
Ok(m) => m,
Err(_) => continue,
};
let hook = match event {
"pre_init" => &manifest.hooks.pre_init,
"post_work_start" => &manifest.hooks.post_work_start,
"pre_pr" => &manifest.hooks.pre_pr,
_ => &None,
};
if let Some(hook_cmd) = hook {
println!(
" {} {} ({})",
style("▶️").cyan().bold(),
style(format!("Plugin hook: {event}")).dim(),
style(&entry.name).cyan()
);
run_hook(&plugin_dir, hook_cmd, &format!("{}/{event}", entry.name))?;
}
}
Ok(())
}
fn plugins_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(std::env::temp_dir)
.join("fledge")
.join("plugins")
}
fn plugin_bin_dir() -> PathBuf {
plugins_dir().join("bin")
}
fn registry_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(std::env::temp_dir)
.join("fledge")
.join("plugins.toml")
}
fn load_registry() -> Result<PluginsRegistry> {
let path = registry_path();
if !path.exists() {
return Ok(PluginsRegistry {
plugins: Vec::new(),
});
}
let content = fs::read_to_string(&path).context("reading plugins.toml")?;
toml::from_str(&content).context("parsing plugins.toml")
}
fn save_registry(registry: &PluginsRegistry) -> Result<()> {
let path = registry_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(registry).context("serializing plugins.toml")?;
fs::write(&path, content).context("writing plugins.toml")
}
fn parse_source_ref(source: &str) -> (&str, Option<&str>) {
if source.starts_with("git@") {
if let Some(rest) = source.strip_prefix("git@") {
if let Some((_, after)) = rest.rsplit_once('@') {
if !after.is_empty() {
let split_pos = source.len() - after.len() - 1;
return (&source[..split_pos], Some(after));
}
}
}
return (source, None);
}
match source.rsplit_once('@') {
Some((before, after))
if !after.is_empty() && !before.is_empty() && !after.contains('/') =>
{
(before, Some(after))
}
_ => (source, None),
}
}
fn normalize_source(source: &str) -> String {
let (base, _) = parse_source_ref(source);
if base.starts_with("https://") || base.starts_with("git@") {
base.to_string()
} else if base.contains('/') {
format!("https://github.com/{}.git", base)
} else {
base.to_string()
}
}
fn extract_name_from_source(source: &str) -> String {
let (base, _) = parse_source_ref(source);
base.rsplit('/')
.next()
.unwrap_or(base)
.trim_end_matches(".git")
.to_string()
}
fn detect_build_command(plugin_dir: &Path) -> Option<(&'static str, Vec<&'static str>)> {
if plugin_dir.join("Cargo.toml").exists() {
Some(("Rust", vec!["cargo", "build", "--release"]))
} else if plugin_dir.join("Package.swift").exists() {
Some(("Swift", vec!["swift", "build", "-c", "release"]))
} else if plugin_dir.join("go.mod").exists() {
Some(("Go", vec!["go", "build", "."]))
} else if plugin_dir.join("package.json").exists() {
Some(("Node", vec!["npm", "install"]))
} else {
None
}
}
fn run_build(plugin_dir: &Path, manifest: &PluginManifest) -> Result<()> {
if let Some(hook) = &manifest.hooks.build {
run_hook(plugin_dir, hook, "build")?;
return Ok(());
}
if let Some((lang, cmd)) = detect_build_command(plugin_dir) {
let sp = crate::spinner::Spinner::start(&format!("Building ({lang}):"));
let status = Command::new(cmd[0])
.args(&cmd[1..])
.current_dir(plugin_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.status()
.with_context(|| format!("running {lang} build"))?;
sp.finish();
if !status.success() {
bail!("Build failed. Check your {lang} toolchain is installed.");
}
}
Ok(())
}
fn validate_command_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.contains('/')
|| name.contains('\\')
|| name.contains('\0')
|| name.starts_with('.')
|| name.starts_with('-')
|| name == ".."
{
bail!(
"Invalid plugin command name '{}'. Names must be alphanumeric with hyphens/underscores.",
name
);
}
Ok(())
}
fn link_commands(
plugin_dir: &Path,
bin_dir: &Path,
manifest: &PluginManifest,
) -> Result<Vec<String>> {
let mut command_names = Vec::new();
for cmd in &manifest.commands {
validate_command_name(&cmd.name)?;
for component in std::path::Path::new(&cmd.binary).components() {
if matches!(component, std::path::Component::ParentDir) {
bail!(
"Plugin '{}' binary '{}' contains path traversal (..)",
manifest.plugin.name,
cmd.binary
);
}
}
let binary_path = plugin_dir.join(&cmd.binary);
if let Ok(canonical_binary) = binary_path.canonicalize() {
let canonical_dir = plugin_dir
.canonicalize()
.unwrap_or_else(|_| plugin_dir.to_path_buf());
if !canonical_binary.starts_with(&canonical_dir) {
bail!(
"Plugin '{}' binary '{}' resolves outside the plugin directory",
manifest.plugin.name,
cmd.binary
);
}
}
if !binary_path.exists() {
let mut hint = format!(
"Plugin '{}' references binary '{}' which does not exist.",
manifest.plugin.name, cmd.binary
);
if let Some((lang, _)) = detect_build_command(plugin_dir) {
hint.push_str(&format!(
"\n This looks like a {} project. Add a build hook to plugin.toml:",
lang
));
hint.push_str("\n [hooks]");
let example = match lang {
"Rust" => "build = \"cargo build --release\"",
"Swift" => "build = \"swift build -c release\"",
"Go" => "build = \"go build .\"",
_ => "build = \"scripts/build.sh\"",
};
hint.push_str(&format!("\n {example}"));
}
bail!("{hint}");
}
make_executable(&binary_path)?;
let link_name = format!("fledge-{}", cmd.name);
let link_path = bin_dir.join(&link_name);
if link_path.exists() || link_path.is_symlink() {
fs::remove_file(&link_path).ok();
}
create_symlink(&binary_path, &link_path).with_context(|| {
format!(
"creating symlink {} -> {}",
link_path.display(),
binary_path.display()
)
})?;
command_names.push(cmd.name.clone());
}
Ok(command_names)
}
fn validate_plugin_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.starts_with('.')
|| name.contains('/')
|| name.contains('\\')
|| name == ".."
{
bail!("Invalid plugin source: repo name '{}' is not safe.", name);
}
Ok(())
}
fn install_plugin(source: &str, force: bool) -> Result<()> {
let (_, git_ref) = parse_source_ref(source);
let url = normalize_source(source);
let repo_name = extract_name_from_source(source);
validate_plugin_name(&repo_name)?;
println!(
"\n{} Installing plugin from: {}",
style("!").yellow().bold(),
style(&url).cyan()
);
println!(
" {} Plugins can execute arbitrary code on your system.",
style("*").yellow()
);
println!(
" {} Only install plugins from sources you trust.\n",
style("*").yellow()
);
if !force {
let confirm = dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(format!("Install plugin '{repo_name}' from {url}?"))
.default(true)
.interact()?;
if !confirm {
bail!("Plugin installation cancelled.");
}
}
let plugins = plugins_dir();
let bin_dir = plugin_bin_dir();
fs::create_dir_all(&plugins)?;
fs::create_dir_all(&bin_dir)?;
let plugin_dir = plugins.join(&repo_name);
let mut registry = load_registry()?;
let existing = registry.plugins.iter().position(|p| p.name == repo_name);
if plugin_dir.exists() {
if !force {
bail!(
"Plugin '{}' is already installed.\n Use {} to reinstall.",
repo_name,
style("--force").cyan()
);
}
fs::remove_dir_all(&plugin_dir).context("removing existing plugin")?;
}
let clone_msg = match git_ref {
Some(r) => format!("Cloning {}@{}:", &url, r),
None => format!("Cloning {}:", &url),
};
let sp = crate::spinner::Spinner::start(&clone_msg);
let mut clone_args = vec!["clone"];
if git_ref.is_none() {
clone_args.push("--depth");
clone_args.push("1");
}
clone_args.push(&url);
let status = Command::new("git")
.args(&clone_args)
.arg(&plugin_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.status()
.context("running git clone")?;
sp.finish();
if !status.success() {
bail!(
"Failed to clone '{}'. Check the repository URL and your network connection.",
source
);
}
if let Some(ref_str) = git_ref {
let status = Command::new("git")
.args(["checkout", ref_str])
.current_dir(&plugin_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.status()
.with_context(|| format!("checking out ref '{ref_str}'"))?;
if !status.success() {
fs::remove_dir_all(&plugin_dir).ok();
bail!(
"Git ref '{}' not found in '{}'. Check available tags with:\n {}",
ref_str,
source,
style(format!("git ls-remote --tags {url}")).cyan()
);
}
}
let manifest_path = plugin_dir.join("plugin.toml");
if !manifest_path.exists() {
fs::remove_dir_all(&plugin_dir).ok();
bail!(
"Repository '{}' has no plugin.toml manifest.\n See {} for the plugin format.",
source,
style("https://github.com/CorvidLabs/fledge#plugins").cyan()
);
}
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")?;
run_build(&plugin_dir, &manifest)?;
let command_names = link_commands(&plugin_dir, &bin_dir, &manifest).inspect_err(|_| {
fs::remove_dir_all(&plugin_dir).ok();
})?;
let (base_source, _) = parse_source_ref(source);
let entry = PluginEntry {
name: repo_name.clone(),
source: base_source.to_string(),
version: manifest.plugin.version.clone(),
installed: chrono::Local::now().format("%Y-%m-%d").to_string(),
commands: command_names.clone(),
pinned_ref: git_ref.map(String::from),
};
if let Some(idx) = existing {
registry.plugins[idx] = entry;
} else {
registry.plugins.push(entry);
}
save_registry(®istry)?;
if let Some(ref pinned) = git_ref {
println!(
"{} Installed {} v{} (pinned to {})",
style("✅").green().bold(),
style(&manifest.plugin.name).green(),
manifest.plugin.version,
style(pinned).cyan()
);
} else {
println!(
"{} Installed {} v{}",
style("✅").green().bold(),
style(&manifest.plugin.name).green(),
manifest.plugin.version
);
}
if !command_names.is_empty() {
println!(" Commands: {}", style(command_names.join(", ")).cyan());
}
if let Some(hook) = &manifest.hooks.post_install {
run_hook(&plugin_dir, hook, "post_install")?;
}
Ok(())
}
fn update_plugins(name: Option<&str>) -> Result<()> {
let registry = load_registry()?;
let targets: Vec<&PluginEntry> = 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() {
println!("{} No plugins installed.", style("*").cyan().bold());
return Ok(());
}
registry.plugins.iter().collect()
}
};
for entry in &targets {
let plugin_dir = plugins_dir().join(&entry.name);
if !plugin_dir.exists() {
println!(
" {} {} — directory missing, reinstall with {}",
style("⚠️").yellow(),
style(&entry.name).yellow(),
style(format!("fledge plugin install {} --force", entry.source)).cyan()
);
continue;
}
if let Some(ref pinned) = entry.pinned_ref {
let latest = find_latest_tag(&plugin_dir);
match latest {
Some(ref tag) if tag != pinned => {
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()
);
}
_ => {
println!(
" {} {} — pinned to {}, already up to date.",
style("✅").green().bold(),
style(&entry.name).green(),
style(pinned).dim()
);
}
}
continue;
}
let sp = crate::spinner::Spinner::start(&format!("Updating {}:", &entry.name));
let status = Command::new("git")
.args(["pull", "--ff-only"])
.current_dir(&plugin_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.status()
.with_context(|| format!("updating {}", entry.name))?;
sp.finish();
if !status.success() {
println!(
" {} {} — git pull failed, try reinstalling with {}",
style("⚠️").yellow(),
style(&entry.name).yellow(),
style(format!("fledge plugin install {} --force", entry.source)).cyan()
);
continue;
}
let manifest_path = plugin_dir.join("plugin.toml");
if manifest_path.exists() {
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")?;
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 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;
}
save_registry(®)?;
println!(
" {} {} → v{}",
style("✅").green().bold(),
style(&entry.name).green(),
manifest.plugin.version
);
}
}
Ok(())
}
fn find_latest_tag(repo_dir: &Path) -> Option<String> {
Command::new("git")
.args(["fetch", "--tags"])
.current_dir(repo_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.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())
}
fn remove_plugin(name: &str) -> Result<()> {
let mut registry = load_registry()?;
let idx = registry
.plugins
.iter()
.position(|p| p.name == name || p.name == format!("fledge-{name}"))
.ok_or_else(|| {
let installed: Vec<&str> = registry.plugins.iter().map(|p| p.name.as_str()).collect();
if installed.is_empty() {
anyhow::anyhow!("No plugins installed.")
} else {
anyhow::anyhow!(
"Plugin '{}' is not installed.\n Installed: {}",
name,
installed.join(", ")
)
}
})?;
let entry = ®istry.plugins[idx];
let bin_dir = plugin_bin_dir();
for cmd_name in &entry.commands {
let link = bin_dir.join(format!("fledge-{cmd_name}"));
fs::remove_file(&link).ok();
}
let plugin_dir = plugins_dir().join(&entry.name);
let post_remove_hook = plugin_dir
.join("plugin.toml")
.exists()
.then(|| {
fs::read_to_string(plugin_dir.join("plugin.toml"))
.ok()
.and_then(|s| toml::from_str::<PluginManifest>(&s).ok())
.and_then(|m| m.hooks.post_remove)
})
.flatten();
if let Some(ref hook) = post_remove_hook {
run_hook(&plugin_dir, hook, "post_remove")?;
}
if plugin_dir.exists() {
fs::remove_dir_all(&plugin_dir).context("removing plugin directory")?;
}
let removed_name = entry.name.clone();
registry.plugins.remove(idx);
save_registry(®istry)?;
println!(
"{} Removed {}",
style("✅").green().bold(),
style(&removed_name).green()
);
Ok(())
}
fn list_plugins(json: bool) -> Result<()> {
let registry = load_registry()?;
if registry.plugins.is_empty() {
if json {
println!("[]");
} else {
println!(
"{} No plugins installed. Use {} to find plugins.",
style("*").cyan().bold(),
style("fledge plugin search").cyan()
);
}
return Ok(());
}
if json {
let entries: Vec<serde_json::Value> = registry
.plugins
.iter()
.map(|p| {
serde_json::json!({
"name": p.name,
"version": p.version,
"source": p.source,
"installed": p.installed,
"commands": p.commands,
"pinned_ref": p.pinned_ref,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
println!("{}", style("Installed plugins:").bold());
let max_name = registry
.plugins
.iter()
.map(|p| p.name.len())
.max()
.unwrap_or(0);
for plugin in ®istry.plugins {
let version_str = match &plugin.pinned_ref {
Some(r) => format!("v{} (pinned: {})", plugin.version, r),
None => format!("v{}", plugin.version),
};
println!(
" {:<width$} {} {}",
style(&plugin.name).green(),
style(&version_str).dim(),
style(format!("({})", plugin.source)).dim(),
width = max_name,
);
if !plugin.commands.is_empty() {
println!(
" {:<width$} Commands: {}",
"",
style(plugin.commands.join(", ")).cyan(),
width = max_name,
);
}
}
Ok(())
}
fn search_plugins(
query: Option<&str>,
author: Option<&str>,
limit: usize,
json: bool,
) -> Result<()> {
let sp = crate::spinner::Spinner::start("Searching GitHub for plugins:");
let config = crate::config::Config::load().ok();
let token = config.as_ref().and_then(|c| c.github_token());
let query_str = crate::search::build_search_query_ex(query, author, "fledge-plugin");
let limit_str = limit.to_string();
let body = crate::github::github_api_get(
"/search/repositories",
token.as_deref(),
&[
("q", &query_str),
("sort", "stars"),
("per_page", &limit_str),
],
)
.context("searching GitHub for plugins")?;
sp.finish();
let items = body["items"].as_array().unwrap_or(&Vec::new()).clone();
if items.is_empty() {
if json {
println!("[]");
} else {
println!(
"{} No plugins found{}.",
style("*").cyan().bold(),
query
.map(|q| format!(" matching '{q}'"))
.unwrap_or_default()
);
}
return Ok(());
}
if json {
let entries: Vec<serde_json::Value> = items
.iter()
.map(|item| {
serde_json::json!({
"name": item["name"],
"full_name": item["full_name"],
"description": item["description"],
"stars": item["stargazers_count"],
"url": item["html_url"],
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
println!("{}", style("Available plugins:").bold());
let max_name = items
.iter()
.filter_map(|i| i["full_name"].as_str())
.map(|n| n.len())
.max()
.unwrap_or(0);
for item in &items {
let full_name = item["full_name"].as_str().unwrap_or("?");
let desc = item["description"].as_str().unwrap_or("(no description)");
let stars = item["stargazers_count"].as_u64().unwrap_or(0);
println!(
" {:<width$} {} {}",
style(full_name).green(),
style(desc).dim(),
style(format!("⭐ {stars}")).yellow(),
width = max_name,
);
}
println!(
"\n Install with: {}",
style("fledge plugin install <owner/repo>").cyan()
);
Ok(())
}
fn run_plugin(name: &str, args: &[String]) -> Result<()> {
let bin_path = resolve_plugin_command(name)
.or_else(|| resolve_plugin_by_name(name))
.ok_or_else(|| {
let hint = match find_commands_for_plugin(name) {
Some(cmds) if !cmds.is_empty() => format!(
"\n Did you mean one of its commands? {}",
style(cmds.join(", ")).cyan()
),
_ => String::new(),
};
anyhow::anyhow!(
"Plugin command '{}' not found.{}\n Run {} to see installed plugins.",
name,
hint,
style("fledge plugin list").cyan()
)
})?;
let status = Command::new(&bin_path)
.args(args)
.status()
.with_context(|| format!("running plugin '{name}'"))?;
if !status.success() {
let code = status.code().unwrap_or(1);
bail!("Plugin '{}' exited with code {}", name, code);
}
Ok(())
}
fn run_hook(plugin_dir: &Path, hook: &str, event: &str) -> Result<()> {
println!(
" {} Running {} hook...",
style("▶️").cyan().bold(),
style(event).dim()
);
let hook_path = plugin_dir.join(hook);
let status = if hook_path.exists() {
let canonical_hook = hook_path
.canonicalize()
.with_context(|| format!("canonicalizing hook path '{}'", hook))?;
let canonical_plugin_dir = plugin_dir
.canonicalize()
.unwrap_or_else(|_| plugin_dir.to_path_buf());
if !canonical_hook.starts_with(&canonical_plugin_dir) {
bail!("Hook path '{}' escapes plugin directory", hook);
}
make_executable(&hook_path)?;
Command::new(&hook_path)
.current_dir(plugin_dir)
.status()
.with_context(|| format!("running {event} hook"))?
} else {
let parts: Vec<&str> = hook.split_whitespace().collect();
if parts.is_empty() {
bail!("Empty hook command for {event}");
}
Command::new(parts[0])
.args(&parts[1..])
.current_dir(plugin_dir)
.status()
.with_context(|| format!("running {event} hook"))?
};
if !status.success() {
let code = status.code().unwrap_or(1);
bail!("Hook '{}' exited with code {}", event, code);
}
Ok(())
}
fn resolve_plugin_by_name(plugin_name: &str) -> Option<PathBuf> {
let registry = load_registry().ok()?;
let entry = registry
.plugins
.iter()
.find(|p| p.name == plugin_name || p.name == format!("fledge-{plugin_name}"))?;
let first_cmd = entry.commands.first()?;
resolve_plugin_command(first_cmd)
}
fn find_commands_for_plugin(plugin_name: &str) -> Option<Vec<String>> {
let registry = load_registry().ok()?;
registry
.plugins
.iter()
.find(|p| p.name == plugin_name || p.name == format!("fledge-{plugin_name}"))
.map(|p| p.commands.clone())
}
fn which_fledge_plugin(name: &str) -> Option<PathBuf> {
let target = format!("fledge-{name}");
let path_var = std::env::var("PATH").ok()?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(&target);
if candidate.exists() {
return Some(candidate);
}
}
None
}
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(path)?;
let mut perms = metadata.permissions();
let mode = perms.mode();
if mode & 0o111 == 0 {
perms.set_mode(mode | 0o755);
fs::set_permissions(path, perms)?;
}
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}
fn create_symlink(original: &Path, link: &Path) -> Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(original, link)?;
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_file(original, link)?;
}
Ok(())
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_github_shorthand() {
assert_eq!(
normalize_source("someone/fledge-deploy"),
"https://github.com/someone/fledge-deploy.git"
);
}
#[test]
fn normalize_github_shorthand_with_ref() {
assert_eq!(
normalize_source("someone/fledge-deploy@v1.0.0"),
"https://github.com/someone/fledge-deploy.git"
);
}
#[test]
fn normalize_full_url() {
let url = "https://github.com/someone/fledge-deploy.git";
assert_eq!(normalize_source(url), url);
}
#[test]
fn normalize_ssh_url() {
let url = "git@github.com:someone/fledge-deploy.git";
assert_eq!(normalize_source(url), url);
}
#[test]
fn extract_name_from_github_shorthand() {
assert_eq!(
extract_name_from_source("someone/fledge-deploy"),
"fledge-deploy"
);
}
#[test]
fn extract_name_with_ref() {
assert_eq!(
extract_name_from_source("someone/fledge-deploy@v1.0.0"),
"fledge-deploy"
);
}
#[test]
fn extract_name_from_full_url() {
assert_eq!(
extract_name_from_source("https://github.com/someone/fledge-deploy.git"),
"fledge-deploy"
);
}
#[test]
fn extract_name_plain() {
assert_eq!(extract_name_from_source("my-plugin"), "my-plugin");
}
#[test]
fn plugin_dir_is_under_config() {
let dir = plugins_dir();
assert!(dir.to_string_lossy().contains("fledge"));
assert!(dir.to_string_lossy().contains("plugins"));
}
#[test]
fn bin_dir_is_under_plugins() {
let dir = plugin_bin_dir();
assert!(dir.ends_with("plugins/bin"));
}
#[test]
fn empty_registry_has_no_plugins() {
let registry = PluginsRegistry {
plugins: Vec::new(),
};
assert!(registry.plugins.is_empty());
}
#[test]
fn registry_roundtrip() {
let registry = PluginsRegistry {
plugins: vec![PluginEntry {
name: "fledge-test".to_string(),
source: "someone/fledge-test".to_string(),
version: "1.0.0".to_string(),
installed: "2026-04-20".to_string(),
commands: vec!["test-cmd".to_string()],
pinned_ref: None,
}],
};
let serialized = toml::to_string_pretty(®istry).unwrap();
let deserialized: PluginsRegistry = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.plugins.len(), 1);
assert_eq!(deserialized.plugins[0].name, "fledge-test");
assert_eq!(deserialized.plugins[0].commands, vec!["test-cmd"]);
assert!(deserialized.plugins[0].pinned_ref.is_none());
}
#[test]
fn registry_roundtrip_with_pinned_ref() {
let registry = PluginsRegistry {
plugins: vec![PluginEntry {
name: "fledge-test".to_string(),
source: "someone/fledge-test".to_string(),
version: "1.0.0".to_string(),
installed: "2026-04-20".to_string(),
commands: vec!["test-cmd".to_string()],
pinned_ref: Some("v1.0.0".to_string()),
}],
};
let serialized = toml::to_string_pretty(®istry).unwrap();
let deserialized: PluginsRegistry = toml::from_str(&serialized).unwrap();
assert_eq!(
deserialized.plugins[0].pinned_ref,
Some("v1.0.0".to_string())
);
}
#[test]
fn parse_source_ref_with_tag() {
let (base, git_ref) = parse_source_ref("someone/fledge-deploy@v1.2.0");
assert_eq!(base, "someone/fledge-deploy");
assert_eq!(git_ref, Some("v1.2.0"));
}
#[test]
fn parse_source_ref_without_tag() {
let (base, git_ref) = parse_source_ref("someone/fledge-deploy");
assert_eq!(base, "someone/fledge-deploy");
assert!(git_ref.is_none());
}
#[test]
fn parse_source_ref_with_branch() {
let (base, git_ref) = parse_source_ref("someone/fledge-deploy@main");
assert_eq!(base, "someone/fledge-deploy");
assert_eq!(git_ref, Some("main"));
}
#[test]
fn parse_source_ref_full_url_with_tag() {
let (base, git_ref) =
parse_source_ref("https://github.com/someone/fledge-deploy.git@v2.0.0");
assert_eq!(base, "https://github.com/someone/fledge-deploy.git");
assert_eq!(git_ref, Some("v2.0.0"));
}
#[test]
fn parse_source_ref_credential_url_no_split() {
let (base, git_ref) = parse_source_ref("https://user:token@github.com/owner/repo.git");
assert_eq!(base, "https://user:token@github.com/owner/repo.git");
assert!(git_ref.is_none());
}
#[test]
fn validate_plugin_name_rejects_dotdot() {
assert!(validate_plugin_name("..").is_err());
}
#[test]
fn validate_plugin_name_rejects_hidden() {
assert!(validate_plugin_name(".secret").is_err());
}
#[test]
fn validate_plugin_name_rejects_slashes() {
assert!(validate_plugin_name("../etc").is_err());
}
#[test]
fn validate_plugin_name_accepts_normal() {
assert!(validate_plugin_name("fledge-deploy").is_ok());
}
#[test]
fn validate_command_name_rejects_slashes() {
assert!(validate_command_name("../evil").is_err());
assert!(validate_command_name("foo/bar").is_err());
}
#[test]
fn validate_command_name_rejects_dot_prefix() {
assert!(validate_command_name(".hidden").is_err());
}
#[test]
fn validate_command_name_rejects_dash_prefix() {
assert!(validate_command_name("-flag").is_err());
}
#[test]
fn validate_command_name_accepts_normal() {
assert!(validate_command_name("deploy").is_ok());
assert!(validate_command_name("my-tool").is_ok());
assert!(validate_command_name("tool_v2").is_ok());
}
#[test]
fn parse_plugin_manifest() {
let manifest_str = r#"
[plugin]
name = "fledge-deploy"
version = "0.1.0"
description = "Deploy to cloud"
author = "someone"
[[commands]]
name = "deploy"
description = "Deploy the project"
binary = "fledge-deploy"
"#;
let manifest: PluginManifest = toml::from_str(manifest_str).unwrap();
assert_eq!(manifest.plugin.name, "fledge-deploy");
assert_eq!(manifest.plugin.version, "0.1.0");
assert_eq!(manifest.commands.len(), 1);
assert_eq!(manifest.commands[0].name, "deploy");
}
#[test]
fn parse_minimal_manifest() {
let manifest_str = r#"
[plugin]
name = "fledge-minimal"
version = "0.1.0"
"#;
let manifest: PluginManifest = toml::from_str(manifest_str).unwrap();
assert_eq!(manifest.plugin.name, "fledge-minimal");
assert!(manifest.commands.is_empty());
}
#[test]
fn parse_manifest_multiple_commands() {
let manifest_str = r#"
[plugin]
name = "fledge-cloud"
version = "0.2.0"
[[commands]]
name = "deploy"
description = "Deploy"
binary = "bin/deploy"
[[commands]]
name = "rollback"
description = "Rollback"
binary = "bin/rollback"
"#;
let manifest: PluginManifest = toml::from_str(manifest_str).unwrap();
assert_eq!(manifest.commands.len(), 2);
assert_eq!(manifest.commands[0].name, "deploy");
assert_eq!(manifest.commands[1].name, "rollback");
}
#[test]
fn resolve_nonexistent_plugin() {
assert!(resolve_plugin_command("definitely-not-installed-xyz").is_none());
}
#[test]
fn which_nonexistent() {
assert!(which_fledge_plugin("definitely-not-installed-xyz").is_none());
}
#[test]
fn install_dir_with_tempdir() {
let tmp = tempfile::tempdir().unwrap();
let plugin_dir = tmp.path().join("test-plugin");
fs::create_dir_all(&plugin_dir).unwrap();
let manifest = r#"
[plugin]
name = "test-plugin"
version = "0.1.0"
"#;
fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
let content = fs::read_to_string(plugin_dir.join("plugin.toml")).unwrap();
let parsed: PluginManifest = toml::from_str(&content).unwrap();
assert_eq!(parsed.plugin.name, "test-plugin");
}
#[test]
fn registry_path_exists() {
let path = registry_path();
assert!(path.to_string_lossy().contains("plugins.toml"));
}
#[test]
fn plugins_dir_structure() {
let pd = plugins_dir();
let bd = plugin_bin_dir();
assert!(bd.starts_with(&pd));
}
#[test]
fn detect_rust_build() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
let result = detect_build_command(tmp.path());
assert!(result.is_some());
let (lang, cmd) = result.unwrap();
assert_eq!(lang, "Rust");
assert_eq!(cmd[0], "cargo");
}
#[test]
fn detect_swift_build() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("Package.swift"), "// swift").unwrap();
let result = detect_build_command(tmp.path());
assert!(result.is_some());
let (lang, _) = result.unwrap();
assert_eq!(lang, "Swift");
}
#[test]
fn detect_go_build() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("go.mod"), "module x").unwrap();
let result = detect_build_command(tmp.path());
assert!(result.is_some());
let (lang, _) = result.unwrap();
assert_eq!(lang, "Go");
}
#[test]
fn detect_node_build() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
let result = detect_build_command(tmp.path());
assert!(result.is_some());
let (lang, _) = result.unwrap();
assert_eq!(lang, "Node");
}
#[test]
fn detect_no_build_system() {
let tmp = tempfile::tempdir().unwrap();
assert!(detect_build_command(tmp.path()).is_none());
}
#[test]
fn parse_manifest_with_build_hook() {
let manifest_str = r#"
[plugin]
name = "fledge-compiled"
version = "0.1.0"
[[commands]]
name = "compiled"
binary = "target/release/fledge-compiled"
[hooks]
build = "cargo build --release"
post_install = "scripts/setup.sh"
"#;
let manifest: PluginManifest = toml::from_str(manifest_str).unwrap();
assert_eq!(
manifest.hooks.build.as_deref(),
Some("cargo build --release")
);
assert_eq!(
manifest.hooks.post_install.as_deref(),
Some("scripts/setup.sh")
);
}
#[test]
fn parse_manifest_with_lifecycle_hooks() {
let manifest_str = r#"
[plugin]
name = "fledge-lint"
version = "0.1.0"
[hooks]
pre_init = "scripts/pre-init.sh"
post_work_start = "scripts/setup-hooks.sh"
pre_pr = "scripts/lint-all.sh"
"#;
let manifest: PluginManifest = toml::from_str(manifest_str).unwrap();
assert_eq!(
manifest.hooks.pre_init.as_deref(),
Some("scripts/pre-init.sh")
);
assert_eq!(
manifest.hooks.post_work_start.as_deref(),
Some("scripts/setup-hooks.sh")
);
assert_eq!(
manifest.hooks.pre_pr.as_deref(),
Some("scripts/lint-all.sh")
);
}
#[test]
fn parse_manifest_lifecycle_hooks_default_none() {
let manifest_str = r#"
[plugin]
name = "fledge-simple"
version = "0.1.0"
"#;
let manifest: PluginManifest = toml::from_str(manifest_str).unwrap();
assert!(manifest.hooks.pre_init.is_none());
assert!(manifest.hooks.post_work_start.is_none());
assert!(manifest.hooks.pre_pr.is_none());
}
}