use crate::paths;
use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use std::path::PathBuf;
const GITHUB_REPO: &str = paths::urls::USER_TOOLS_REPO;
const FRAMEWORK_VERSION: &str = env!("CARGO_PKG_VERSION");
const SUPPORTED_TARGETS: &[&str] = &[
"aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", ];
pub struct BinaryManager {
cache_dir: PathBuf,
version: String,
target: String,
}
impl BinaryManager {
pub fn new() -> Result<Self> {
let cache_dir = paths::user::bin_dir();
let version = FRAMEWORK_VERSION.to_string();
let target = Self::detect_target()?;
Ok(Self {
cache_dir,
version,
target,
})
}
pub fn with_version(version: String) -> Result<Self> {
let cache_dir = paths::user::bin_dir();
let target = Self::detect_target()?;
Ok(Self {
cache_dir,
version,
target,
})
}
fn detect_target() -> Result<String> {
let target = match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => "aarch64-apple-darwin",
("macos", "x86_64") => "x86_64-apple-darwin",
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
("linux", "aarch64") => "aarch64-unknown-linux-gnu",
(os, arch) => {
return Err(anyhow::anyhow!(
"Unsupported platform: {}-{}. Pre-built binaries not available.",
os,
arch
))
}
};
Ok(target.to_string())
}
pub fn is_prebuilt_available(&self) -> bool {
SUPPORTED_TARGETS.contains(&self.target.as_str())
}
pub fn cache_dir(&self) -> &PathBuf {
&self.cache_dir
}
pub fn version(&self) -> &str {
&self.version
}
pub fn target(&self) -> &str {
&self.target
}
pub async fn resolve(&self, node_name: &str) -> Result<PathBuf> {
if let Some(cached) = self.find_cached(node_name) {
tracing::debug!("Using cached binary: {}", cached.display());
return Ok(cached);
}
if self.is_prebuilt_available() {
match self.download(node_name).await {
Ok(path) => {
tracing::info!("Downloaded binary for {}: {}", node_name, path.display());
return Ok(path);
}
Err(e) => {
tracing::warn!("Failed to download binary for {}: {}", node_name, e);
}
}
} else {
tracing::info!("Pre-built binary not available for {} on {}", node_name, self.target);
}
self.cargo_install(node_name).await
}
fn find_cached(&self, node_name: &str) -> Option<PathBuf> {
let binary_name = self.binary_name(node_name);
let path = self.cache_dir.join(&binary_name);
if path.exists() && path.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = path.metadata() {
if metadata.permissions().mode() & 0o111 != 0 {
return Some(path);
}
}
}
#[cfg(not(unix))]
{
return Some(path);
}
}
let symlink_path = self.cache_dir.join(node_name);
if symlink_path.exists() {
if let Ok(resolved) = std::fs::canonicalize(&symlink_path) {
if resolved.exists() {
return Some(resolved);
}
}
}
None
}
fn binary_name(&self, node_name: &str) -> String {
format!("{}-{}-{}", node_name, self.version, self.target)
}
fn tarball_name(&self, node_name: &str) -> String {
format!("{}-{}-{}.tar.gz", node_name, self.version, self.target)
}
async fn download(&self, node_name: &str) -> Result<PathBuf> {
let client = Self::build_client()?;
let token = Self::github_token();
let tarball_name = self.tarball_name(node_name);
let release_tag = format!("v{}", self.version);
let release_url = format!(
"https://api.github.com/repos/{}/releases/tags/{}",
GITHUB_REPO, release_tag
);
println!("📦 Downloading {} (v{})...", node_name, self.version);
let mut request = client.get(&release_url);
if let Some(ref token) = token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request
.send()
.await
.context("Failed to fetch release info from GitHub")?;
if !response.status().is_success() {
let status = response.status();
if status.as_u16() == 404 {
return Err(anyhow::anyhow!(
"Release v{} not found. The node binary may not be published yet.\n\
Falling back to cargo install...",
self.version
));
}
return Err(anyhow::anyhow!("Failed to get release info: HTTP {}", status));
}
let release: serde_json::Value = response.json().await?;
let assets = release["assets"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("No assets in release"))?;
let asset = assets
.iter()
.find(|a| a["name"].as_str().map(|n| n == tarball_name).unwrap_or(false))
.ok_or_else(|| {
anyhow::anyhow!(
"Binary '{}' not found in release v{}.\n\
Available binaries may not include this node or platform.",
tarball_name,
self.version
)
})?;
let download_url = if token.is_some() {
asset["url"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("No API URL for asset"))?
} else {
asset["browser_download_url"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?
};
let size = asset["size"].as_u64().unwrap_or(0);
self.download_and_extract(node_name, download_url, size, token).await
}
async fn download_and_extract(
&self,
node_name: &str,
url: &str,
size: u64,
token: Option<String>,
) -> Result<PathBuf> {
let client = Self::build_client()?;
let pb = ProgressBar::new(size);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("#>-"),
);
let mut request = client.get(url);
if let Some(ref token) = token {
request = request
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/octet-stream");
}
let response = request.send().await.context("Failed to download binary")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
}
let temp_dir = tempfile::tempdir()?;
let temp_file = temp_dir.path().join("binary.tar.gz");
let mut file = tokio::fs::File::create(&temp_file).await?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Error downloading chunk")?;
file.write_all(&chunk).await?;
pb.inc(chunk.len() as u64);
}
file.flush().await?;
pb.finish_with_message("Download complete");
tokio::fs::create_dir_all(&self.cache_dir).await?;
let tar_gz = std::fs::File::open(&temp_file)?;
let tar = flate2::read::GzDecoder::new(tar_gz);
let mut archive = tar::Archive::new(tar);
let extract_dir = temp_dir.path().join("extract");
std::fs::create_dir_all(&extract_dir)?;
archive.unpack(&extract_dir)?;
let extracted_binary = extract_dir.join(node_name);
if !extracted_binary.exists() {
return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
}
let binary_name = self.binary_name(node_name);
let dest_path = self.cache_dir.join(&binary_name);
tokio::fs::copy(&extracted_binary, &dest_path).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
perms.set_mode(0o755);
tokio::fs::set_permissions(&dest_path, perms).await?;
}
let symlink_path = self.cache_dir.join(node_name);
if symlink_path.exists() || symlink_path.is_symlink() {
tokio::fs::remove_file(&symlink_path).await.ok();
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_file(&dest_path, &symlink_path)?;
}
println!("✅ Installed {} to {}", node_name, dest_path.display());
Ok(dest_path)
}
async fn cargo_install(&self, node_name: &str) -> Result<PathBuf> {
println!(
"⚠️ Pre-built binary not available for {} on {}",
self.target, node_name
);
println!("🔨 Compiling via cargo install...");
let crate_name = format!("mecha10-nodes-{}", node_name);
let output = tokio::process::Command::new("cargo")
.args([
"install",
&crate_name,
"--version",
&self.version,
"--root",
&self.cache_dir.to_string_lossy(),
])
.output()
.await
.context("Failed to run cargo install")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("cargo install failed for {}: {}", crate_name, stderr));
}
let binary_path = self.cache_dir.join("bin").join(node_name);
if !binary_path.exists() {
return Err(anyhow::anyhow!(
"Binary not found after cargo install: {}",
binary_path.display()
));
}
println!("✅ Compiled {} via cargo install", node_name);
Ok(binary_path)
}
fn build_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent("mecha10-cli")
.build()
.context("Failed to build HTTP client")
}
fn github_token() -> Option<String> {
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
return Some(token);
}
if let Ok(token) = std::env::var("GH_TOKEN") {
return Some(token);
}
let output = std::process::Command::new("gh").args(["auth", "token"]).output().ok()?;
if output.status.success() {
let token = String::from_utf8(output.stdout).ok()?;
let token = token.trim();
if !token.is_empty() {
return Some(token.to_string());
}
}
None
}
pub fn list_cached(&self) -> Result<Vec<(String, String, String)>> {
let mut binaries = Vec::new();
if !self.cache_dir.exists() {
return Ok(binaries);
}
for entry in std::fs::read_dir(&self.cache_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if entry.file_type()?.is_symlink() {
continue;
}
let parts: Vec<&str> = name.rsplitn(3, '-').collect();
if parts.len() >= 3 {
let target_parts = format!("{}-{}", parts[1], parts[0]);
let version = parts.get(2).map(|s| s.to_string()).unwrap_or_default();
let remaining: String = parts.iter().skip(2).rev().map(|s| *s).collect::<Vec<_>>().join("-");
if !remaining.is_empty() {
binaries.push((remaining, version, target_parts));
}
}
}
Ok(binaries)
}
pub async fn remove(&self, node_name: &str) -> Result<()> {
let binary_name = self.binary_name(node_name);
let binary_path = self.cache_dir.join(&binary_name);
let symlink_path = self.cache_dir.join(node_name);
if binary_path.exists() {
tokio::fs::remove_file(&binary_path).await?;
println!("Removed {}", binary_path.display());
}
if symlink_path.exists() || symlink_path.is_symlink() {
tokio::fs::remove_file(&symlink_path).await?;
println!("Removed symlink {}", symlink_path.display());
}
Ok(())
}
pub async fn clear_cache(&self) -> Result<()> {
if self.cache_dir.exists() {
tokio::fs::remove_dir_all(&self.cache_dir).await?;
println!("Cleared binary cache at {}", self.cache_dir.display());
}
Ok(())
}
}
impl Default for BinaryManager {
fn default() -> Self {
Self::new().expect("Failed to create BinaryManager")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_binary_name() {
let manager = BinaryManager {
cache_dir: PathBuf::from("/tmp"),
version: "0.1.44".to_string(),
target: "aarch64-apple-darwin".to_string(),
};
assert_eq!(manager.binary_name("speaker"), "speaker-0.1.44-aarch64-apple-darwin");
}
#[test]
fn test_tarball_name() {
let manager = BinaryManager {
cache_dir: PathBuf::from("/tmp"),
version: "0.1.44".to_string(),
target: "x86_64-apple-darwin".to_string(),
};
assert_eq!(manager.tarball_name("motor"), "motor-0.1.44-x86_64-apple-darwin.tar.gz");
}
#[test]
fn test_prebuilt_available() {
let manager = BinaryManager {
cache_dir: PathBuf::from("/tmp"),
version: "0.1.44".to_string(),
target: "aarch64-apple-darwin".to_string(),
};
assert!(manager.is_prebuilt_available());
let manager_arm_linux = BinaryManager {
cache_dir: PathBuf::from("/tmp"),
version: "0.1.44".to_string(),
target: "aarch64-unknown-linux-gnu".to_string(),
};
assert!(manager_arm_linux.is_prebuilt_available());
let manager_unsupported = BinaryManager {
cache_dir: PathBuf::from("/tmp"),
version: "0.1.44".to_string(),
target: "aarch64-unknown-linux-musl".to_string(),
};
assert!(!manager_unsupported.is_prebuilt_available());
}
}