use anyhow::{Context, Result};
use clap::Subcommand;
use colored::Colorize;
use mockforge_plugin_core::manifest::ManifestLoader;
use mockforge_plugin_loader::installer::{InstallOptions, PluginInstaller, PluginLoaderConfig};
use mockforge_plugin_registry::{
api::{PublishRequest, RegistryClient},
config::{load_config, set_token, clear_token},
manifest::{validate_manifest, PluginManifest as RegistryPluginManifest},
PluginCategory, SearchQuery, SortOrder,
};
use ring::digest::{Context, SHA256};
use std::fs;
use std::path::Path;
use tempfile;
fn calculate_checksum(data: &[u8]) -> String {
let mut context = Context::new(&SHA256);
context.update(data);
let digest = context.finish();
hex::encode(digest.as_ref())
}
async fn build_plugin_wasm(path: &str) -> Result<()> {
use std::process::Command;
check_cargo().context("Cargo not found")?;
check_wasm_target().context("wasm32-wasi target not available")?;
std::env::set_current_dir(path)
.with_context(|| format!("Failed to change to directory {}", path))?;
let status = Command::new("cargo")
.args(["build", "--target", "wasm32-wasi", "--release"])
.status()
.context("Failed to execute cargo build")?;
if !status.success() {
anyhow::bail!("Plugin build failed");
}
Ok(())
}
fn convert_to_registry_manifest(
core_manifest: &mockforge_plugin_core::PluginManifest,
) -> Result<RegistryPluginManifest> {
use mockforge_plugin_registry::manifest::{AuthorInfo, PluginCategory};
let plugin_info = &core_manifest.plugin;
let author = if let Some(core_author) = &plugin_info.author {
AuthorInfo {
name: core_author.name.clone(),
email: core_author.email.clone(),
url: core_author.url.clone(),
}
} else {
AuthorInfo {
name: "Unknown".to_string(),
email: None,
url: None,
}
};
let category = if plugin_info.types.contains(&"auth".to_string()) {
PluginCategory::Auth
} else if plugin_info.types.contains(&"template".to_string()) {
PluginCategory::Template
} else if plugin_info.types.contains(&"response".to_string()) {
PluginCategory::Response
} else if plugin_info.types.contains(&"datasource".to_string()) {
PluginCategory::DataSource
} else if plugin_info.types.contains(&"middleware".to_string()) {
PluginCategory::Middleware
} else if plugin_info.types.contains(&"testing".to_string()) {
PluginCategory::Testing
} else if plugin_info.types.contains(&"observability".to_string()) {
PluginCategory::Observability
} else {
PluginCategory::Other
};
let mut dependencies = std::collections::HashMap::new();
for dep in &core_manifest.dependencies {
dependencies.insert(dep.id.to_string(), dep.version.clone());
}
let min_mockforge_version = core_manifest.metadata
.get("runtime")
.and_then(|runtime| runtime.as_object())
.and_then(|obj| obj.get("min_mockforge_version"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(RegistryPluginManifest {
name: plugin_info.id.to_string(),
version: plugin_info.version.to_string(),
description: plugin_info.description.clone().unwrap_or_else(|| "No description".to_string()),
author,
license: plugin_info.license.clone().unwrap_or_else(|| "Unknown".to_string()),
repository: plugin_info.repository.clone(),
homepage: plugin_info.homepage.clone(),
tags: plugin_info.keywords.clone(),
category,
min_mockforge_version,
dependencies,
})
}
fn check_cargo() -> Result<()> {
std::process::Command::new("cargo")
.arg("--version")
.output()
.context("Cargo not found")?;
Ok(())
}
fn check_wasm_target() -> Result<()> {
let output = std::process::Command::new("rustup")
.args(["target", "list", "--installed"])
.output()
.context("Failed to check installed targets")?;
let installed = String::from_utf8(output.stdout)
.context("Failed to parse target list")?;
if !installed.lines().any(|line| line.trim() == "wasm32-wasi") {
anyhow::bail!("wasm32-wasi target not installed. Run: rustup target add wasm32-wasi");
}
Ok(())
}
#[derive(Debug, Subcommand)]
pub enum RegistryCommand {
Search {
query: Option<String>,
#[arg(long)]
category: Option<String>,
#[arg(long)]
tags: Option<String>,
#[arg(long, default_value = "relevance")]
sort: String,
#[arg(long, default_value = "0")]
page: usize,
#[arg(long, default_value = "20")]
per_page: usize,
},
Info {
name: String,
#[arg(long)]
version: Option<String>,
},
Install {
plugin: String,
#[arg(long)]
force: bool,
},
Publish {
#[arg(default_value = ".")]
path: String,
#[arg(long)]
dry_run: bool,
},
Yank {
name: String,
version: String,
},
Login {
#[arg(long)]
token: Option<String>,
},
Logout,
Config,
}
pub async fn handle_registry_command(cmd: RegistryCommand) -> Result<()> {
match cmd {
RegistryCommand::Search {
query,
category,
tags,
sort,
page,
per_page,
} => search_plugins(query, category, tags, sort, page, per_page).await,
RegistryCommand::Info { name, version } => show_plugin_info(&name, version.as_deref()).await,
RegistryCommand::Install { plugin, force } => install_from_registry(&plugin, force).await,
RegistryCommand::Publish { path, dry_run } => publish_plugin(&path, dry_run).await,
RegistryCommand::Yank { name, version } => yank_version(&name, &version).await,
RegistryCommand::Login { token } => login(token).await,
RegistryCommand::Logout => logout().await,
RegistryCommand::Config => show_config().await,
}
}
async fn search_plugins(
query: Option<String>,
category: Option<String>,
tags: Option<String>,
sort: String,
page: usize,
per_page: usize,
) -> Result<()> {
let config = load_config().await?;
let client = RegistryClient::new(config)?;
let sort_order = match sort.as_str() {
"downloads" => SortOrder::Downloads,
"rating" => SortOrder::Rating,
"recent" => SortOrder::Recent,
"name" => SortOrder::Name,
_ => SortOrder::Relevance,
};
let category_filter = category.and_then(|c| match c.to_lowercase().as_str() {
"auth" => Some(PluginCategory::Auth),
"template" => Some(PluginCategory::Template),
"response" => Some(PluginCategory::Response),
"datasource" => Some(PluginCategory::DataSource),
"middleware" => Some(PluginCategory::Middleware),
"testing" => Some(PluginCategory::Testing),
"observability" => Some(PluginCategory::Observability),
_ => None,
});
let tags_vec = tags
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let search_query = SearchQuery {
query,
category: category_filter,
tags: tags_vec,
sort: sort_order,
page,
per_page,
};
let results = client.search(search_query).await?;
if results.plugins.is_empty() {
println!("{}", "No plugins found".yellow());
return Ok(());
}
println!("\n{} {} plugins found\n", "📦".blue(), results.total);
for plugin in &results.plugins {
println!("{} {} v{}", "•".blue(), plugin.name.bold(), plugin.version);
println!(" {}", plugin.description);
println!(
" {} {} downloads • ⭐ {:.1}/5.0 • {}",
"↓".green(),
plugin.downloads,
plugin.rating,
plugin.license.dimmed()
);
if !plugin.tags.is_empty() {
println!(" Tags: {}", plugin.tags.join(", ").dimmed());
}
println!();
}
let total_pages = (results.total + results.per_page - 1) / results.per_page;
println!(
"Page {}/{} • Showing {} results",
results.page + 1,
total_pages,
results.plugins.len()
);
Ok(())
}
async fn show_plugin_info(name: &str, version: Option<&str>) -> Result<()> {
let config = load_config().await?;
let client = RegistryClient::new(config)?;
let plugin = client.get_plugin(name).await?;
println!("\n{} {}", "📦".blue(), plugin.name.bold());
println!("Version: {}", plugin.version.green());
println!("Category: {:?}", plugin.category);
println!("License: {}", plugin.license);
println!("\n{}", plugin.description);
if !plugin.tags.is_empty() {
println!("\nTags: {}", plugin.tags.join(", "));
}
println!("\n{}", "Statistics:".bold());
println!(" Downloads: {}", plugin.downloads);
println!(" Rating: ⭐ {:.1}/5.0 ({} reviews)", plugin.rating, plugin.reviews_count);
println!(" Created: {}", plugin.created_at);
println!(" Updated: {}", plugin.updated_at);
if let Some(repo) = &plugin.repository {
println!("\nRepository: {}", repo);
}
if let Some(homepage) = &plugin.homepage {
println!("Homepage: {}", homepage);
}
println!("\n{}", "Available versions:".bold());
for ver in &plugin.versions {
if !ver.yanked {
println!(" • {} ({})", ver.version, ver.published_at);
}
}
if let Some(ver) = version {
if let Some(version_entry) = plugin.versions.iter().find(|v| v.version == ver) {
println!("\n{}", format!("Version {} details:", ver).bold());
println!(" Download URL: {}", version_entry.download_url);
println!(" Checksum: {}", version_entry.checksum);
println!(" Size: {} bytes", version_entry.size);
}
}
Ok(())
}
async fn install_from_registry(plugin_spec: &str, force: bool) -> Result<()> {
let config = load_config().await?;
let client = RegistryClient::new(config)?;
let (name, version) = if let Some(pos) = plugin_spec.find('@') {
let (n, v) = plugin_spec.split_at(pos);
(n, Some(&v[1..]))
} else {
(plugin_spec, None)
};
println!("{} Installing {} from registry...", "📦".blue(), name.bold());
let plugin = client.get_plugin(name).await?;
let target_version = version.unwrap_or(&plugin.version);
let version_entry = plugin
.versions
.iter()
.find(|v| v.version == target_version)
.context(format!("Version {} not found", target_version))?;
if version_entry.yanked {
anyhow::bail!("Version {} has been yanked", target_version);
}
println!("{} Downloading version {}...", "↓".green(), target_version);
let data = client.download(&version_entry.download_url).await?;
let calculated_checksum = calculate_checksum(&data);
if calculated_checksum != version_entry.checksum {
anyhow::bail!(
"Checksum verification failed! Expected: {}, Got: {}",
version_entry.checksum,
calculated_checksum
);
}
println!("{} Checksum verified", "✓".green());
let temp_file = tempfile::NamedTempFile::new()
.context("Failed to create temporary file")?;
fs::write(&temp_file, &data)
.context("Failed to write plugin data to temporary file")?;
let loader_config = PluginLoaderConfig::default();
let installer = PluginInstaller::new(loader_config)
.context("Failed to create plugin installer")?;
let install_options = InstallOptions {
force,
skip_validation: false,
verify_signature: true,
expected_checksum: None,
};
let plugin_id = installer
.install(temp_file.path().to_string_lossy().as_ref(), install_options)
.await
.context("Failed to install plugin")?;
println!(
"{} {} {} installed successfully!",
"✓".green(),
plugin_id,
target_version
);
Ok(())
}
async fn publish_plugin(path: &str, dry_run: bool) -> Result<()> {
let config = load_config().await?;
if config.token.is_none() {
anyhow::bail!("Not logged in. Run 'mockforge plugin registry login' first.");
}
let client = RegistryClient::new(config)?;
println!("{} Loading plugin manifest from {}...", "📄".blue(), path);
let manifest_path = Path::new(path).join("plugin.yaml");
if !manifest_path.exists() {
anyhow::bail!("Plugin manifest not found at: {}", manifest_path.display());
}
let core_manifest = ManifestLoader::load_and_validate_from_file(&manifest_path)
.context("Failed to load and validate plugin manifest")?;
println!("{} Manifest loaded and validated", "✓".green());
let target_dir = Path::new(path).join("target").join("wasm32-wasi").join("release");
let wasm_file = target_dir.join(format!("{}.wasm", core_manifest.id()));
if !wasm_file.exists() {
println!("{} Building plugin WASM module...", "🔨".blue());
build_plugin_wasm(path).await?;
println!("{} Plugin built successfully", "✓".green());
} else {
println!("{} Using existing WASM file: {}", "📦".blue(), wasm_file.display());
}
let wasm_data = fs::read(&wasm_file)
.context(format!("Failed to read WASM file: {}", wasm_file.display()))?;
let checksum = calculate_checksum(&wasm_data);
let size = wasm_data.len() as u64;
println!("{} Checksum calculated: {}", "🔐".blue(), checksum);
let registry_manifest = convert_to_registry_manifest(&core_manifest)?;
validate_manifest(®istry_manifest)
.context("Registry manifest validation failed")?;
let publish_request = PublishRequest {
name: registry_manifest.name.clone(),
version: registry_manifest.version.clone(),
description: registry_manifest.description.clone(),
author: registry_manifest.author.clone(),
license: registry_manifest.license.clone(),
repository: registry_manifest.repository.clone(),
homepage: registry_manifest.homepage.clone(),
tags: registry_manifest.tags.clone(),
category: registry_manifest.category.clone(),
checksum,
size,
min_mockforge_version: registry_manifest.min_mockforge_version.clone(),
};
if dry_run {
println!("{} Dry run - validation passed!", "✓".green());
println!(" Name: {}", publish_request.name);
println!(" Version: {}", publish_request.version);
println!(" Checksum: {}", publish_request.checksum);
println!(" Size: {} bytes", publish_request.size);
return Ok(());
}
println!("{} Publishing plugin...", "📦".blue());
let response = client.publish(publish_request).await
.context("Failed to publish plugin")?;
println!("{} Plugin published successfully!", "✓".green());
println!(" Upload URL: {}", response.upload_url);
if !response.message.is_empty() {
println!(" Message: {}", response.message);
}
Ok(())
}
async fn yank_version(name: &str, version: &str) -> Result<()> {
let config = load_config().await?;
if config.token.is_none() {
anyhow::bail!("Not logged in. Run 'mockforge plugin registry login' first.");
}
let client = RegistryClient::new(config)?;
println!("{} Yanking {} {}...", "⚠".yellow(), name, version);
client.yank(name, version).await?;
println!("{} Version yanked successfully", "✓".green());
Ok(())
}
async fn login(token: Option<String>) -> Result<()> {
let token = if let Some(t) = token {
t
} else {
println!("Enter your API token:");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
input.trim().to_string()
};
set_token(token).await?;
println!("{} Successfully logged in", "✓".green());
Ok(())
}
async fn logout() -> Result<()> {
clear_token().await?;
println!("{} Successfully logged out", "✓".green());
Ok(())
}
async fn show_config() -> Result<()> {
let config = load_config().await?;
println!("\n{}", "Registry Configuration:".bold());
println!(" URL: {}", config.url);
println!(" Timeout: {}s", config.timeout);
println!(" Token: {}", if config.token.is_some() { "Set" } else { "Not set" });
if !config.alternative_registries.is_empty() {
println!("\n{}", "Alternative Registries:".bold());
for reg in &config.alternative_registries {
println!(" • {}", reg);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use mockforge_plugin_core::{PluginCapabilities, PluginId, PluginVersion};
use mockforge_plugin_core::manifest::models::{PluginInfo, PluginManifest};
use std::collections::HashMap;
#[test]
fn test_convert_manifest_without_min_version() {
let plugin_info = PluginInfo {
id: PluginId::new("test-plugin").unwrap(),
name: "Test Plugin".to_string(),
version: PluginVersion::new("1.0.0").unwrap(),
description: Some("A test plugin".to_string()),
author: None,
types: vec!["auth".to_string()],
homepage: None,
repository: None,
license: Some("MIT".to_string()),
keywords: vec!["test".to_string()],
};
let core_manifest = PluginManifest {
manifest_version: "1.0".to_string(),
plugin: plugin_info,
capabilities: PluginCapabilities::default(),
dependencies: Vec::new(),
config_schema: None,
metadata: HashMap::new(),
};
let result = convert_to_registry_manifest(&core_manifest);
assert!(result.is_ok());
let registry_manifest = result.unwrap();
assert_eq!(registry_manifest.name, "test-plugin");
assert_eq!(registry_manifest.version, "1.0.0");
assert!(registry_manifest.min_mockforge_version.is_none());
}
#[test]
fn test_convert_manifest_with_min_version() {
let plugin_info = PluginInfo {
id: PluginId::new("test-plugin").unwrap(),
name: "Test Plugin".to_string(),
version: PluginVersion::new("1.0.0").unwrap(),
description: Some("A test plugin".to_string()),
author: None,
types: vec!["auth".to_string()],
homepage: None,
repository: None,
license: Some("MIT".to_string()),
keywords: vec!["test".to_string()],
};
let mut metadata = HashMap::new();
let mut runtime = serde_json::Map::new();
runtime.insert(
"min_mockforge_version".to_string(),
serde_json::Value::String("0.1.0".to_string()),
);
metadata.insert("runtime".to_string(), serde_json::Value::Object(runtime));
let core_manifest = PluginManifest {
manifest_version: "1.0".to_string(),
plugin: plugin_info,
capabilities: PluginCapabilities::default(),
dependencies: Vec::new(),
config_schema: None,
metadata,
};
let result = convert_to_registry_manifest(&core_manifest);
assert!(result.is_ok());
let registry_manifest = result.unwrap();
assert_eq!(registry_manifest.name, "test-plugin");
assert_eq!(registry_manifest.version, "1.0.0");
assert_eq!(
registry_manifest.min_mockforge_version,
Some("0.1.0".to_string())
);
}
#[test]
fn test_convert_manifest_with_invalid_runtime_type() {
let plugin_info = PluginInfo {
id: PluginId::new("test-plugin").unwrap(),
name: "Test Plugin".to_string(),
version: PluginVersion::new("1.0.0").unwrap(),
description: Some("A test plugin".to_string()),
author: None,
types: vec!["auth".to_string()],
homepage: None,
repository: None,
license: Some("MIT".to_string()),
keywords: vec!["test".to_string()],
};
let mut metadata = HashMap::new();
metadata.insert("runtime".to_string(), serde_json::Value::String("invalid".to_string()));
let core_manifest = PluginManifest {
manifest_version: "1.0".to_string(),
plugin: plugin_info,
capabilities: PluginCapabilities::default(),
dependencies: Vec::new(),
config_schema: None,
metadata,
};
let result = convert_to_registry_manifest(&core_manifest);
assert!(result.is_ok());
let registry_manifest = result.unwrap();
assert!(registry_manifest.min_mockforge_version.is_none());
}
}