1use crate::paths;
34use anyhow::{Context, Result};
35use indicatif::{ProgressBar, ProgressStyle};
36use std::path::PathBuf;
37
38const GITHUB_REPO: &str = paths::urls::USER_TOOLS_REPO;
40
41const FRAMEWORK_VERSION: &str = env!("CARGO_PKG_VERSION");
43
44const SUPPORTED_TARGETS: &[&str] = &[
48 "aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", ];
53
54pub struct BinaryManager {
56 cache_dir: PathBuf,
58 version: String,
60 target: String,
62}
63
64impl BinaryManager {
65 pub fn new() -> Result<Self> {
67 let cache_dir = paths::user::bin_dir();
68 let version = FRAMEWORK_VERSION.to_string();
69 let target = Self::detect_target()?;
70
71 Ok(Self {
72 cache_dir,
73 version,
74 target,
75 })
76 }
77
78 pub fn with_version(version: String) -> Result<Self> {
80 let cache_dir = paths::user::bin_dir();
81 let target = Self::detect_target()?;
82
83 Ok(Self {
84 cache_dir,
85 version,
86 target,
87 })
88 }
89
90 fn detect_target() -> Result<String> {
92 let target = match (std::env::consts::OS, std::env::consts::ARCH) {
93 ("macos", "aarch64") => "aarch64-apple-darwin",
94 ("macos", "x86_64") => "x86_64-apple-darwin",
95 ("linux", "x86_64") => "x86_64-unknown-linux-gnu",
97 ("linux", "aarch64") => "aarch64-unknown-linux-gnu",
98 (os, arch) => {
99 return Err(anyhow::anyhow!(
100 "Unsupported platform: {}-{}. Pre-built binaries not available.",
101 os,
102 arch
103 ))
104 }
105 };
106 Ok(target.to_string())
107 }
108
109 pub fn is_prebuilt_available(&self) -> bool {
111 SUPPORTED_TARGETS.contains(&self.target.as_str())
112 }
113
114 pub fn cache_dir(&self) -> &PathBuf {
116 &self.cache_dir
117 }
118
119 pub fn version(&self) -> &str {
121 &self.version
122 }
123
124 pub fn target(&self) -> &str {
126 &self.target
127 }
128
129 pub async fn resolve(&self, node_name: &str) -> Result<PathBuf> {
144 if let Some(cached) = self.find_cached(node_name) {
146 tracing::debug!("Using cached binary: {}", cached.display());
147 return Ok(cached);
148 }
149
150 if self.is_prebuilt_available() {
152 match self.download(node_name).await {
153 Ok(path) => {
154 tracing::info!("Downloaded binary for {}: {}", node_name, path.display());
155 return Ok(path);
156 }
157 Err(e) => {
158 tracing::warn!("Failed to download binary for {}: {}", node_name, e);
159 }
161 }
162 } else {
163 tracing::info!("Pre-built binary not available for {} on {}", node_name, self.target);
164 }
165
166 self.cargo_install(node_name).await
168 }
169
170 fn find_cached(&self, node_name: &str) -> Option<PathBuf> {
174 let binary_name = self.binary_name(node_name);
175 let path = self.cache_dir.join(&binary_name);
176
177 if path.exists() && path.is_file() {
178 #[cfg(unix)]
180 {
181 use std::os::unix::fs::PermissionsExt;
182 if let Ok(metadata) = path.metadata() {
183 if metadata.permissions().mode() & 0o111 != 0 {
184 return Some(path);
185 }
186 }
187 }
188
189 #[cfg(not(unix))]
190 {
191 return Some(path);
192 }
193 }
194
195 let symlink_path = self.cache_dir.join(node_name);
197 if symlink_path.exists() {
198 if let Ok(resolved) = std::fs::canonicalize(&symlink_path) {
199 if resolved.exists() {
200 return Some(resolved);
201 }
202 }
203 }
204
205 None
206 }
207
208 fn binary_name(&self, node_name: &str) -> String {
210 format!("{}-{}-{}", node_name, self.version, self.target)
211 }
212
213 fn tarball_name(&self, node_name: &str) -> String {
215 format!("{}-{}-{}.tar.gz", node_name, self.version, self.target)
216 }
217
218 async fn download(&self, node_name: &str) -> Result<PathBuf> {
220 let client = Self::build_client()?;
221 let token = Self::github_token();
222
223 let tarball_name = self.tarball_name(node_name);
225 let release_tag = format!("v{}", self.version);
226
227 let release_url = format!(
229 "https://api.github.com/repos/{}/releases/tags/{}",
230 GITHUB_REPO, release_tag
231 );
232
233 println!("📦 Downloading {} (v{})...", node_name, self.version);
234
235 let mut request = client.get(&release_url);
236 if let Some(ref token) = token {
237 request = request.header("Authorization", format!("Bearer {}", token));
238 }
239
240 let response = request
241 .send()
242 .await
243 .context("Failed to fetch release info from GitHub")?;
244
245 if !response.status().is_success() {
246 let status = response.status();
247 if status.as_u16() == 404 {
248 return Err(anyhow::anyhow!(
249 "Release v{} not found. The node binary may not be published yet.\n\
250 Falling back to cargo install...",
251 self.version
252 ));
253 }
254 return Err(anyhow::anyhow!("Failed to get release info: HTTP {}", status));
255 }
256
257 let release: serde_json::Value = response.json().await?;
258
259 let assets = release["assets"]
261 .as_array()
262 .ok_or_else(|| anyhow::anyhow!("No assets in release"))?;
263
264 let asset = assets
265 .iter()
266 .find(|a| a["name"].as_str().map(|n| n == tarball_name).unwrap_or(false))
267 .ok_or_else(|| {
268 anyhow::anyhow!(
269 "Binary '{}' not found in release v{}.\n\
270 Available binaries may not include this node or platform.",
271 tarball_name,
272 self.version
273 )
274 })?;
275
276 let download_url = if token.is_some() {
278 asset["url"]
279 .as_str()
280 .ok_or_else(|| anyhow::anyhow!("No API URL for asset"))?
281 } else {
282 asset["browser_download_url"]
283 .as_str()
284 .ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?
285 };
286
287 let size = asset["size"].as_u64().unwrap_or(0);
288
289 self.download_and_extract(node_name, download_url, size, token).await
291 }
292
293 async fn download_and_extract(
295 &self,
296 node_name: &str,
297 url: &str,
298 size: u64,
299 token: Option<String>,
300 ) -> Result<PathBuf> {
301 let client = Self::build_client()?;
302
303 let pb = ProgressBar::new(size);
305 pb.set_style(
306 ProgressStyle::default_bar()
307 .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
308 .unwrap()
309 .progress_chars("#>-"),
310 );
311
312 let mut request = client.get(url);
313
314 if let Some(ref token) = token {
316 request = request
317 .header("Authorization", format!("Bearer {}", token))
318 .header("Accept", "application/octet-stream");
319 }
320
321 let response = request.send().await.context("Failed to download binary")?;
322
323 if !response.status().is_success() {
324 return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
325 }
326
327 let temp_dir = tempfile::tempdir()?;
329 let temp_file = temp_dir.path().join("binary.tar.gz");
330
331 let mut file = tokio::fs::File::create(&temp_file).await?;
332 let mut stream = response.bytes_stream();
333
334 use futures_util::StreamExt;
335 use tokio::io::AsyncWriteExt;
336
337 while let Some(chunk) = stream.next().await {
338 let chunk = chunk.context("Error downloading chunk")?;
339 file.write_all(&chunk).await?;
340 pb.inc(chunk.len() as u64);
341 }
342
343 file.flush().await?;
344 pb.finish_with_message("Download complete");
345
346 tokio::fs::create_dir_all(&self.cache_dir).await?;
348
349 let tar_gz = std::fs::File::open(&temp_file)?;
351 let tar = flate2::read::GzDecoder::new(tar_gz);
352 let mut archive = tar::Archive::new(tar);
353
354 let extract_dir = temp_dir.path().join("extract");
356 std::fs::create_dir_all(&extract_dir)?;
357 archive.unpack(&extract_dir)?;
358
359 let extracted_binary = extract_dir.join(node_name);
361 if !extracted_binary.exists() {
362 return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
363 }
364
365 let binary_name = self.binary_name(node_name);
367 let dest_path = self.cache_dir.join(&binary_name);
368
369 tokio::fs::copy(&extracted_binary, &dest_path).await?;
370
371 #[cfg(unix)]
373 {
374 use std::os::unix::fs::PermissionsExt;
375 let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
376 perms.set_mode(0o755);
377 tokio::fs::set_permissions(&dest_path, perms).await?;
378 }
379
380 let symlink_path = self.cache_dir.join(node_name);
382 if symlink_path.exists() || symlink_path.is_symlink() {
383 tokio::fs::remove_file(&symlink_path).await.ok();
384 }
385
386 #[cfg(unix)]
387 {
388 std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
389 }
390
391 #[cfg(windows)]
392 {
393 std::os::windows::fs::symlink_file(&dest_path, &symlink_path)?;
394 }
395
396 println!("✅ Installed {} to {}", node_name, dest_path.display());
397
398 Ok(dest_path)
399 }
400
401 async fn cargo_install(&self, node_name: &str) -> Result<PathBuf> {
403 println!(
404 "⚠️ Pre-built binary not available for {} on {}",
405 self.target, node_name
406 );
407 println!("🔨 Compiling via cargo install...");
408
409 let crate_name = format!("mecha10-nodes-{}", node_name);
410
411 let output = tokio::process::Command::new("cargo")
412 .args([
413 "install",
414 &crate_name,
415 "--version",
416 &self.version,
417 "--root",
418 &self.cache_dir.to_string_lossy(),
419 ])
420 .output()
421 .await
422 .context("Failed to run cargo install")?;
423
424 if !output.status.success() {
425 let stderr = String::from_utf8_lossy(&output.stderr);
426 return Err(anyhow::anyhow!("cargo install failed for {}: {}", crate_name, stderr));
427 }
428
429 let binary_path = self.cache_dir.join("bin").join(node_name);
431 if !binary_path.exists() {
432 return Err(anyhow::anyhow!(
433 "Binary not found after cargo install: {}",
434 binary_path.display()
435 ));
436 }
437
438 println!("✅ Compiled {} via cargo install", node_name);
439
440 Ok(binary_path)
441 }
442
443 fn build_client() -> Result<reqwest::Client> {
445 reqwest::Client::builder()
446 .user_agent("mecha10-cli")
447 .build()
448 .context("Failed to build HTTP client")
449 }
450
451 fn github_token() -> Option<String> {
453 if let Ok(token) = std::env::var("GITHUB_TOKEN") {
455 return Some(token);
456 }
457 if let Ok(token) = std::env::var("GH_TOKEN") {
458 return Some(token);
459 }
460
461 let output = std::process::Command::new("gh").args(["auth", "token"]).output().ok()?;
463
464 if output.status.success() {
465 let token = String::from_utf8(output.stdout).ok()?;
466 let token = token.trim();
467 if !token.is_empty() {
468 return Some(token.to_string());
469 }
470 }
471
472 None
473 }
474
475 pub fn list_cached(&self) -> Result<Vec<(String, String, String)>> {
477 let mut binaries = Vec::new();
478
479 if !self.cache_dir.exists() {
480 return Ok(binaries);
481 }
482
483 for entry in std::fs::read_dir(&self.cache_dir)? {
484 let entry = entry?;
485 let name = entry.file_name().to_string_lossy().to_string();
486
487 if entry.file_type()?.is_symlink() {
489 continue;
490 }
491
492 let parts: Vec<&str> = name.rsplitn(3, '-').collect();
494 if parts.len() >= 3 {
495 let target_parts = format!("{}-{}", parts[1], parts[0]);
497 let version = parts.get(2).map(|s| s.to_string()).unwrap_or_default();
498
499 let remaining: String = parts.iter().skip(2).rev().map(|s| *s).collect::<Vec<_>>().join("-");
501 if !remaining.is_empty() {
502 binaries.push((remaining, version, target_parts));
503 }
504 }
505 }
506
507 Ok(binaries)
508 }
509
510 pub async fn remove(&self, node_name: &str) -> Result<()> {
512 let binary_name = self.binary_name(node_name);
513 let binary_path = self.cache_dir.join(&binary_name);
514 let symlink_path = self.cache_dir.join(node_name);
515
516 if binary_path.exists() {
517 tokio::fs::remove_file(&binary_path).await?;
518 println!("Removed {}", binary_path.display());
519 }
520
521 if symlink_path.exists() || symlink_path.is_symlink() {
522 tokio::fs::remove_file(&symlink_path).await?;
523 println!("Removed symlink {}", symlink_path.display());
524 }
525
526 Ok(())
527 }
528
529 pub async fn clear_cache(&self) -> Result<()> {
531 if self.cache_dir.exists() {
532 tokio::fs::remove_dir_all(&self.cache_dir).await?;
533 println!("Cleared binary cache at {}", self.cache_dir.display());
534 }
535 Ok(())
536 }
537}
538
539impl Default for BinaryManager {
540 fn default() -> Self {
541 Self::new().expect("Failed to create BinaryManager")
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 #[test]
550 fn test_binary_name() {
551 let manager = BinaryManager {
552 cache_dir: PathBuf::from("/tmp"),
553 version: "0.1.44".to_string(),
554 target: "aarch64-apple-darwin".to_string(),
555 };
556
557 assert_eq!(manager.binary_name("speaker"), "speaker-0.1.44-aarch64-apple-darwin");
558 }
559
560 #[test]
561 fn test_tarball_name() {
562 let manager = BinaryManager {
563 cache_dir: PathBuf::from("/tmp"),
564 version: "0.1.44".to_string(),
565 target: "x86_64-apple-darwin".to_string(),
566 };
567
568 assert_eq!(manager.tarball_name("motor"), "motor-0.1.44-x86_64-apple-darwin.tar.gz");
569 }
570
571 #[test]
572 fn test_prebuilt_available() {
573 let manager = BinaryManager {
574 cache_dir: PathBuf::from("/tmp"),
575 version: "0.1.44".to_string(),
576 target: "aarch64-apple-darwin".to_string(),
577 };
578 assert!(manager.is_prebuilt_available());
579
580 let manager_arm_linux = BinaryManager {
582 cache_dir: PathBuf::from("/tmp"),
583 version: "0.1.44".to_string(),
584 target: "aarch64-unknown-linux-gnu".to_string(),
585 };
586 assert!(manager_arm_linux.is_prebuilt_available());
587
588 let manager_unsupported = BinaryManager {
590 cache_dir: PathBuf::from("/tmp"),
591 version: "0.1.44".to_string(),
592 target: "aarch64-unknown-linux-musl".to_string(),
593 };
594 assert!(!manager_unsupported.is_prebuilt_available());
595 }
596}