use anyhow::{bail, Context, Result};
use console::style;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::trust::parse_source_ref;
mod create;
mod install;
mod list;
mod publish;
mod remove;
mod run_plugin;
mod search;
mod update;
mod validate;
mod wasm;
#[cfg(test)]
mod tests;
use create::create_plugin;
use install::install_action;
use list::{audit_plugins, list_plugins};
use publish::publish_plugin;
use remove::remove_plugin;
use run_plugin::{run_hook, run_plugin_cmd};
use search::search_plugins;
use update::update_plugins;
use validate::validate_plugin;
#[cfg(test)]
use install::check_tier_capabilities;
#[cfg(test)]
use run_plugin::{apply_protocol, resolve_plugin_source_dir, which_fledge_plugin};
pub const DEFAULT_PLUGINS: &[&str] = &[
"CorvidLabs/fledge-plugin-github@v0.4.0",
"CorvidLabs/fledge-plugin-deps@v0.1.0",
"CorvidLabs/fledge-plugin-metrics@v0.2.0",
];
const PLUGINS_INSTALL_SCHEMA: u32 = 1;
const PLUGINS_UPDATE_SCHEMA: u32 = 1;
const PLUGINS_REMOVE_SCHEMA: u32 = 1;
const PLUGINS_LIST_SCHEMA: u32 = 1;
const PLUGINS_AUDIT_SCHEMA: u32 = 1;
const PLUGINS_SEARCH_SCHEMA: u32 = 1;
const PLUGINS_CREATE_SCHEMA: u32 = 1;
const PLUGINS_PUBLISH_SCHEMA: u32 = 1;
#[derive(Debug, Deserialize)]
struct PluginManifest {
pub(super) plugin: PluginMeta,
#[serde(default)]
pub(super) commands: Vec<PluginCommand>,
#[serde(default)]
pub(super) hooks: PluginHooks,
#[serde(default)]
pub(super) capabilities: PluginCapabilities,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginCapabilities {
#[serde(default)]
pub exec: bool,
#[serde(default)]
pub store: bool,
#[serde(default)]
pub metadata: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filesystem: Option<String>,
#[serde(default)]
pub network: bool,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PluginMeta {
pub(super) name: String,
pub(super) version: String,
pub(super) description: Option<String>,
pub(super) author: Option<String>,
pub(super) protocol: Option<String>,
#[serde(default)]
pub(super) runtime: Option<String>,
}
impl PluginMeta {
fn is_wasm(&self) -> bool {
self.runtime.as_deref() == Some("wasm")
}
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PluginCommand {
pub(super) name: String,
pub(super) description: Option<String>,
pub(super) binary: String,
}
#[derive(Debug, Deserialize, Default)]
struct PluginHooks {
pub(super) build: Option<String>,
pub(super) post_install: Option<String>,
pub(super) post_remove: Option<String>,
pub(super) pre_init: Option<String>,
pub(super) post_work_start: Option<String>,
pub(super) pre_push: Option<String>,
}
impl PluginHooks {
fn has_any(&self) -> bool {
self.build.is_some()
|| self.post_install.is_some()
|| self.post_remove.is_some()
|| self.pre_init.is_some()
|| self.post_work_start.is_some()
|| self.pre_push.is_some()
}
fn iter_defined(&self) -> Vec<(&str, &str)> {
let mut hooks = Vec::new();
if let Some(ref c) = self.pre_push {
hooks.push(("pre_push", c.as_str()));
}
if let Some(ref c) = self.build {
hooks.push(("build", c.as_str()));
}
if let Some(ref c) = self.post_install {
hooks.push(("post_install", c.as_str()));
}
if let Some(ref c) = self.post_remove {
hooks.push(("post_remove", c.as_str()));
}
if let Some(ref c) = self.pre_init {
hooks.push(("pre_init", c.as_str()));
}
if let Some(ref c) = self.post_work_start {
hooks.push(("post_work_start", c.as_str()));
}
hooks
}
}
#[derive(Debug, Serialize, Deserialize)]
struct PluginsRegistry {
#[serde(default)]
pub(super) plugins: Vec<PluginEntry>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct PluginEntry {
pub(super) name: String,
pub(super) source: String,
pub(super) version: String,
pub(super) installed: String,
#[serde(default)]
pub(super) commands: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) pinned_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) capabilities: Option<PluginCapabilities>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) runtime: Option<String>,
}
pub struct PluginOptions {
pub action: PluginAction,
pub json: bool,
}
pub enum PluginAction {
Install {
source: Option<String>,
force: bool,
defaults: bool,
},
Remove {
name: String,
},
Update {
name: Option<String>,
defaults: bool,
},
List,
Audit,
Search {
query: Option<String>,
author: Option<String>,
limit: usize,
},
Run {
name: String,
args: Vec<String>,
},
Publish {
path: PathBuf,
org: Option<String>,
private: bool,
description: Option<String>,
yes: bool,
},
Create {
name: String,
output: PathBuf,
description: Option<String>,
yes: bool,
wasm: bool,
},
Validate {
path: PathBuf,
strict: bool,
json: bool,
},
}
pub fn run(opts: PluginOptions) -> Result<()> {
match opts.action {
PluginAction::Install {
source,
force,
defaults,
} => install_action(source.as_deref(), force, defaults, opts.json),
PluginAction::Remove { name } => remove_plugin(&name, opts.json),
PluginAction::Update { name, defaults } => {
update_plugins(name.as_deref(), defaults, opts.json)
}
PluginAction::List => list_plugins(opts.json),
PluginAction::Audit => audit_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_cmd(&name, &args),
PluginAction::Publish {
path,
org,
private,
description,
yes,
} => publish_plugin(
&path,
org.as_deref(),
private,
description.as_deref(),
yes,
opts.json,
),
PluginAction::Create {
name,
output,
description,
yes,
wasm,
} => create_plugin(&name, &output, description.as_deref(), yes, wasm, opts.json),
PluginAction::Validate { path, strict, json } => validate_plugin(&path, strict, json),
}
}
pub fn run_lifecycle_hook(event: &str) -> Result<()> {
let registry = load_registry()?;
for entry in ®istry.plugins {
match &entry.capabilities {
Some(caps) if caps.exec => {}
_ => continue,
}
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_push" => &manifest.hooks.pre_push,
_ => &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(())
}
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);
}
run_plugin::which_fledge_plugin(name)
}
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 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 apply_git_auth(cmd: &mut Command) {
let config = crate::config::Config::load().ok();
let token = config.as_ref().and_then(|c| c.github_token());
if let Some(ref t) = token {
use base64::Engine;
let credentials = format!("x-access-token:{}", t);
let encoded = base64::engine::general_purpose::STANDARD.encode(&credentials);
let header_value = format!("Authorization: Basic {}", encoded);
let existing: usize = std::env::var("GIT_CONFIG_COUNT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
cmd.env("GIT_CONFIG_COUNT", (existing + 1).to_string())
.env(format!("GIT_CONFIG_KEY_{existing}"), "http.extraheader")
.env(format!("GIT_CONFIG_VALUE_{existing}"), &header_value);
}
}
#[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(())
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<()> {
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(())
}