mecha10_cli/services/
binary_manager.rs

1//! Binary Manager service for downloading and caching node binaries from GitHub releases
2//!
3//! This service implements the Node Binary Distribution architecture, providing:
4//! - Downloading pre-built node binaries from GitHub releases
5//! - Caching binaries to `~/.mecha10/bin/`
6//! - Platform detection and target triple resolution
7//! - Fallback to cargo install for unsupported platforms
8//!
9//! # Design
10//!
11//! - Binaries are downloaded from GitHub releases as tarballs
12//! - Binary naming: `{node}-{version}-{target}.tar.gz`
13//! - Cached to: `~/.mecha10/bin/{node}-{version}-{target}`
14//! - Symlink created: `~/.mecha10/bin/{node}` -> latest version
15//!
16//! # Usage
17//!
18//! ```no_run
19//! use mecha10_cli::services::BinaryManager;
20//!
21//! # async fn example() -> anyhow::Result<()> {
22//! let manager = BinaryManager::new()?;
23//!
24//! // Resolve binary path (downloads if needed)
25//! let path = manager.resolve("speaker").await?;
26//!
27//! // Execute the binary
28//! std::process::Command::new(&path).spawn()?;
29//! # Ok(())
30//! # }
31//! ```
32
33use crate::paths;
34use anyhow::{Context, Result};
35use indicatif::{ProgressBar, ProgressStyle};
36use std::path::PathBuf;
37
38/// GitHub repository for node binaries (public distribution repo)
39const GITHUB_REPO: &str = paths::urls::USER_TOOLS_REPO;
40
41/// Framework version (should match workspace Cargo.toml)
42const FRAMEWORK_VERSION: &str = env!("CARGO_PKG_VERSION");
43
44/// Supported pre-built targets
45/// Note: Linux uses gnu targets for better compatibility with system libraries
46/// (audio, USB, etc.) since robots typically run Raspberry Pi OS / Ubuntu (glibc)
47const SUPPORTED_TARGETS: &[&str] = &[
48    "aarch64-apple-darwin",      // macOS Apple Silicon
49    "x86_64-apple-darwin",       // macOS Intel
50    "x86_64-unknown-linux-gnu",  // Linux x86_64 (glibc)
51    "aarch64-unknown-linux-gnu", // Linux ARM64 (Pi, Jetson) - glibc
52];
53
54/// Service for managing node binary downloads and caching
55pub struct BinaryManager {
56    /// Binary cache directory (~/.mecha10/bin)
57    cache_dir: PathBuf,
58    /// Framework version for binary resolution
59    version: String,
60    /// Current platform target triple
61    target: String,
62}
63
64impl BinaryManager {
65    /// Create a new binary manager
66    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    /// Create a binary manager with a specific version
79    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    /// Detect the current platform's target triple
91    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 uses gnu for compatibility with system libraries (audio, USB, etc.)
96            ("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    /// Check if pre-built binaries are available for current platform
110    pub fn is_prebuilt_available(&self) -> bool {
111        SUPPORTED_TARGETS.contains(&self.target.as_str())
112    }
113
114    /// Get the binary cache directory
115    pub fn cache_dir(&self) -> &PathBuf {
116        &self.cache_dir
117    }
118
119    /// Get the current version
120    pub fn version(&self) -> &str {
121        &self.version
122    }
123
124    /// Get the current target
125    pub fn target(&self) -> &str {
126        &self.target
127    }
128
129    /// Resolve binary path for a node, downloading if needed
130    ///
131    /// Resolution strategy:
132    /// 1. Check if cached binary exists for current version/target
133    /// 2. If not, try to download from GitHub releases
134    /// 3. If download fails and platform unsupported, fall back to cargo install
135    ///
136    /// # Arguments
137    ///
138    /// * `node_name` - Name of the node (e.g., "speaker", "motor")
139    ///
140    /// # Returns
141    ///
142    /// Path to the executable binary
143    pub async fn resolve(&self, node_name: &str) -> Result<PathBuf> {
144        // Check cache first
145        if let Some(cached) = self.find_cached(node_name) {
146            tracing::debug!("Using cached binary: {}", cached.display());
147            return Ok(cached);
148        }
149
150        // Try to download
151        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                    // Fall through to cargo install
160                }
161            }
162        } else {
163            tracing::info!("Pre-built binary not available for {} on {}", node_name, self.target);
164        }
165
166        // Fallback: cargo install
167        self.cargo_install(node_name).await
168    }
169
170    /// Check for cached binary
171    ///
172    /// Looks for `{node}-{version}-{target}` in the cache directory
173    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            // Verify it's executable
179            #[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        // Also check symlink without version
196        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    /// Build the binary name for a node
209    fn binary_name(&self, node_name: &str) -> String {
210        format!("{}-{}-{}", node_name, self.version, self.target)
211    }
212
213    /// Build the tarball name for a node
214    fn tarball_name(&self, node_name: &str) -> String {
215        format!("{}-{}-{}.tar.gz", node_name, self.version, self.target)
216    }
217
218    /// Download binary from GitHub releases
219    async fn download(&self, node_name: &str) -> Result<PathBuf> {
220        let client = Self::build_client()?;
221        let token = Self::github_token();
222
223        // Build release asset URL
224        let tarball_name = self.tarball_name(node_name);
225        let release_tag = format!("v{}", self.version);
226
227        // Try to get release info
228        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        // Find the asset
260        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        // Get download URL
277        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        // Download and extract
290        self.download_and_extract(node_name, download_url, size, token).await
291    }
292
293    /// Download and extract a binary tarball
294    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        // Create progress bar
304        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        // Add auth and accept headers for API downloads
315        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        // Download to temp file
328        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        // Create cache directory
347        tokio::fs::create_dir_all(&self.cache_dir).await?;
348
349        // Extract tarball
350        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        // Extract to temp, then move to cache
355        let extract_dir = temp_dir.path().join("extract");
356        std::fs::create_dir_all(&extract_dir)?;
357        archive.unpack(&extract_dir)?;
358
359        // Find the binary in the extracted files (should be just the node name)
360        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        // Move to cache with versioned name
366        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        // Make executable
372        #[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        // Create/update symlink
381        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    /// Fallback: install via cargo
402    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        // cargo install puts binary in {root}/bin/{name}
430        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    /// Build an HTTP client
444    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    /// Get GitHub token from environment or gh CLI
452    fn github_token() -> Option<String> {
453        // Try environment variables
454        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        // Try gh CLI
462        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    /// List all cached binaries
476    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            // Skip symlinks (they don't have version info)
488            if entry.file_type()?.is_symlink() {
489                continue;
490            }
491
492            // Parse name: {node}-{version}-{target}
493            let parts: Vec<&str> = name.rsplitn(3, '-').collect();
494            if parts.len() >= 3 {
495                // Note: rsplitn reverses order
496                let target_parts = format!("{}-{}", parts[1], parts[0]);
497                let version = parts.get(2).map(|s| s.to_string()).unwrap_or_default();
498
499                // Extract node name from remaining parts
500                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    /// Remove a cached binary
511    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    /// Clear all cached binaries
530    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        // aarch64-unknown-linux-gnu is now supported (for Pi, Jetson with glibc)
581        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        // musl variant is not supported (we use gnu for system library compatibility)
589        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}