1use anyhow::{Context, Result};
47use std::collections::HashMap;
48use std::path::PathBuf;
49use std::process::Stdio;
50use tokio::process::{Child, Command};
51
52const GITHUB_REPO: &str = "mecha-industries/user-tools";
54
55const FRAMEWORK_VERSION: &str = env!("CARGO_PKG_VERSION");
57
58const SUPPORTED_TARGETS: &[&str] = &[
62 "aarch64-apple-darwin",
63 "x86_64-apple-darwin",
64 "x86_64-unknown-linux-gnu",
65 "aarch64-unknown-linux-gnu",
66];
67
68#[derive(Clone)]
70pub struct NodeResolver {
71 cache_dir: PathBuf,
73 version: String,
75 target: String,
77}
78
79impl NodeResolver {
80 pub fn new() -> Result<Self> {
82 let cache_dir = Self::default_cache_dir()?;
83 let version = FRAMEWORK_VERSION.to_string();
84 let target = Self::detect_target()?;
85
86 Ok(Self {
87 cache_dir,
88 version,
89 target,
90 })
91 }
92
93 pub fn with_version(version: String) -> Result<Self> {
95 let cache_dir = Self::default_cache_dir()?;
96 let target = Self::detect_target()?;
97
98 Ok(Self {
99 cache_dir,
100 version,
101 target,
102 })
103 }
104
105 fn default_cache_dir() -> Result<PathBuf> {
107 let home = std::env::var("HOME").context("HOME environment variable not set")?;
108 Ok(PathBuf::from(home).join(".mecha10").join("bin"))
109 }
110
111 fn detect_target() -> Result<String> {
113 let target = match (std::env::consts::OS, std::env::consts::ARCH) {
114 ("macos", "aarch64") => "aarch64-apple-darwin",
115 ("macos", "x86_64") => "x86_64-apple-darwin",
116 ("linux", "x86_64") => "x86_64-unknown-linux-gnu",
118 ("linux", "aarch64") => "aarch64-unknown-linux-gnu",
119 (os, arch) => {
120 return Err(anyhow::anyhow!(
121 "Unsupported platform: {}-{}. Pre-built binaries not available.",
122 os,
123 arch
124 ))
125 }
126 };
127 Ok(target.to_string())
128 }
129
130 pub fn is_framework_node(name: &str) -> bool {
132 name.starts_with("@mecha10/")
133 }
134
135 pub fn short_name(name: &str) -> Option<&str> {
139 name.strip_prefix("@mecha10/")
140 }
141
142 pub fn is_prebuilt_available(&self) -> bool {
144 SUPPORTED_TARGETS.contains(&self.target.as_str())
145 }
146
147 pub fn version(&self) -> &str {
149 &self.version
150 }
151
152 pub fn target(&self) -> &str {
154 &self.target
155 }
156
157 pub async fn resolve(&self, node_name: &str) -> Result<PathBuf> {
167 if let Some(cached) = self.find_cached(node_name) {
169 tracing::debug!("Using cached binary: {}", cached.display());
170 return Ok(cached);
171 }
172
173 if self.is_prebuilt_available() {
175 match self.download(node_name).await {
176 Ok(path) => {
177 tracing::info!("Downloaded binary for {}: {}", node_name, path.display());
178 return Ok(path);
179 }
180 Err(e) => {
181 tracing::warn!("Failed to download binary for {}: {}", node_name, e);
182 }
183 }
184 } else {
185 tracing::warn!(
186 "Pre-built binary not available for {} on {}",
187 node_name,
188 self.target
189 );
190 }
191
192 Err(anyhow::anyhow!(
193 "Could not resolve binary for node '{}'. \
194 Pre-built binary not available for target '{}'.",
195 node_name,
196 self.target
197 ))
198 }
199
200 pub async fn spawn(&self, node_name: &str, env: HashMap<String, String>) -> Result<Child> {
211 let binary_path = self.resolve(node_name).await?;
212
213 tracing::info!("Spawning node {} from {}", node_name, binary_path.display());
214
215 let child = Command::new(&binary_path)
216 .envs(env)
217 .stdout(Stdio::piped())
218 .stderr(Stdio::piped())
219 .spawn()
220 .with_context(|| format!("Failed to spawn node {}", node_name))?;
221
222 Ok(child)
223 }
224
225 pub async fn spawn_and_wait(&self, node_name: &str, env: HashMap<String, String>) -> Result<()> {
227 let binary_path = self.resolve(node_name).await?;
228
229 tracing::info!("Running node {} from {}", node_name, binary_path.display());
230
231 let status = Command::new(&binary_path)
232 .envs(env)
233 .status()
234 .await
235 .with_context(|| format!("Failed to run node {}", node_name))?;
236
237 if !status.success() {
238 return Err(anyhow::anyhow!(
239 "Node {} exited with status: {}",
240 node_name,
241 status
242 ));
243 }
244
245 Ok(())
246 }
247
248 fn find_cached(&self, node_name: &str) -> Option<PathBuf> {
250 let binary_name = self.binary_name(node_name);
251 let path = self.cache_dir.join(&binary_name);
252
253 if path.exists() && path.is_file() {
254 #[cfg(unix)]
255 {
256 use std::os::unix::fs::PermissionsExt;
257 if let Ok(metadata) = path.metadata() {
258 if metadata.permissions().mode() & 0o111 != 0 {
259 return Some(path);
260 }
261 }
262 }
263
264 #[cfg(not(unix))]
265 {
266 return Some(path);
267 }
268 }
269
270 let symlink_path = self.cache_dir.join(node_name);
272 if symlink_path.exists() {
273 if let Ok(resolved) = std::fs::canonicalize(&symlink_path) {
274 if resolved.exists() {
275 return Some(resolved);
276 }
277 }
278 }
279
280 None
281 }
282
283 fn binary_name(&self, node_name: &str) -> String {
285 format!("{}-{}-{}", node_name, self.version, self.target)
286 }
287
288 fn tarball_name(&self, node_name: &str) -> String {
290 format!("{}-{}-{}.tar.gz", node_name, self.version, self.target)
291 }
292
293 async fn download(&self, node_name: &str) -> Result<PathBuf> {
295 let client = reqwest::Client::builder()
296 .user_agent("mecha10-node-resolver")
297 .build()
298 .context("Failed to build HTTP client")?;
299
300 let tarball_name = self.tarball_name(node_name);
301 let release_tag = format!("v{}", self.version);
302
303 let release_url = format!(
305 "https://api.github.com/repos/{}/releases/tags/{}",
306 GITHUB_REPO, release_tag
307 );
308
309 tracing::info!("Downloading {} (v{})...", node_name, self.version);
310
311 let response = client
312 .get(&release_url)
313 .send()
314 .await
315 .context("Failed to fetch release info from GitHub")?;
316
317 if !response.status().is_success() {
318 let status = response.status();
319 if status.as_u16() == 404 {
320 return Err(anyhow::anyhow!(
321 "Release v{} not found. Node binaries may not be published yet.",
322 self.version
323 ));
324 }
325 return Err(anyhow::anyhow!("Failed to get release info: HTTP {}", status));
326 }
327
328 let release: serde_json::Value = response.json().await?;
329
330 let assets = release["assets"]
332 .as_array()
333 .ok_or_else(|| anyhow::anyhow!("No assets in release"))?;
334
335 let asset = assets
336 .iter()
337 .find(|a| a["name"].as_str().map(|n| n == tarball_name).unwrap_or(false))
338 .ok_or_else(|| {
339 anyhow::anyhow!(
340 "Binary '{}' not found in release v{}.",
341 tarball_name,
342 self.version
343 )
344 })?;
345
346 let download_url = asset["browser_download_url"]
347 .as_str()
348 .ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?;
349
350 let response = client
352 .get(download_url)
353 .send()
354 .await
355 .context("Failed to download binary")?;
356
357 if !response.status().is_success() {
358 return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
359 }
360
361 let temp_dir = tempdir()?;
363 let temp_file = temp_dir.path().join("binary.tar.gz");
364
365 let bytes = response.bytes().await?;
366 tokio::fs::write(&temp_file, &bytes).await?;
367
368 tokio::fs::create_dir_all(&self.cache_dir).await?;
370
371 let tar_gz = std::fs::File::open(&temp_file)?;
373 let tar = flate2::read::GzDecoder::new(tar_gz);
374 let mut archive = tar::Archive::new(tar);
375
376 let extract_dir = temp_dir.path().join("extract");
377 std::fs::create_dir_all(&extract_dir)?;
378 archive.unpack(&extract_dir)?;
379
380 let extracted_binary = extract_dir.join(node_name);
382 if !extracted_binary.exists() {
383 return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
384 }
385
386 let binary_name = self.binary_name(node_name);
388 let dest_path = self.cache_dir.join(&binary_name);
389
390 tokio::fs::copy(&extracted_binary, &dest_path).await?;
391
392 #[cfg(unix)]
394 {
395 use std::os::unix::fs::PermissionsExt;
396 let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
397 perms.set_mode(0o755);
398 tokio::fs::set_permissions(&dest_path, perms).await?;
399 }
400
401 let symlink_path = self.cache_dir.join(node_name);
403 if symlink_path.exists() || symlink_path.is_symlink() {
404 tokio::fs::remove_file(&symlink_path).await.ok();
405 }
406
407 #[cfg(unix)]
408 {
409 std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
410 }
411
412 tracing::info!("Installed {} to {}", node_name, dest_path.display());
413
414 Ok(dest_path)
415 }
416
417 pub async fn resolve_all(&self, nodes: &[String]) -> Result<HashMap<String, PathBuf>> {
419 use futures_util::future::join_all;
420
421 let framework_nodes: Vec<_> = nodes
422 .iter()
423 .filter(|n| Self::is_framework_node(n))
424 .filter_map(|n| Self::short_name(n).map(|s| s.to_string()))
425 .collect();
426
427 if framework_nodes.is_empty() {
428 return Ok(HashMap::new());
429 }
430
431 tracing::info!("Resolving {} framework nodes...", framework_nodes.len());
432
433 let futures = framework_nodes.iter().map(|name| {
434 let resolver = self.clone();
435 let name = name.clone();
436 async move {
437 let result = resolver.resolve(&name).await;
438 (name, result)
439 }
440 });
441
442 let results = join_all(futures).await;
443
444 let mut resolved = HashMap::new();
445 for (name, result) in results {
446 match result {
447 Ok(path) => {
448 resolved.insert(name, path);
449 }
450 Err(e) => {
451 return Err(anyhow::anyhow!("Failed to resolve node '{}': {}", name, e));
452 }
453 }
454 }
455
456 tracing::info!("All {} framework nodes ready", resolved.len());
457 Ok(resolved)
458 }
459}
460
461impl Default for NodeResolver {
462 fn default() -> Self {
463 Self::new().expect("Failed to create NodeResolver")
464 }
465}
466
467struct TempDir {
469 path: PathBuf,
470}
471
472impl TempDir {
473 fn path(&self) -> &std::path::Path {
474 &self.path
475 }
476}
477
478impl Drop for TempDir {
479 fn drop(&mut self) {
480 let _ = std::fs::remove_dir_all(&self.path);
481 }
482}
483
484fn tempdir() -> std::io::Result<TempDir> {
485 let mut path = std::env::temp_dir();
486 path.push(format!("mecha10-node-resolver-{}-{}", std::process::id(), rand_u64()));
487 std::fs::create_dir_all(&path)?;
488 Ok(TempDir { path })
489}
490
491fn rand_u64() -> u64 {
492 use std::time::{SystemTime, UNIX_EPOCH};
493 SystemTime::now()
494 .duration_since(UNIX_EPOCH)
495 .map(|d| d.as_nanos() as u64)
496 .unwrap_or(0)
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn test_is_framework_node() {
505 assert!(NodeResolver::is_framework_node("@mecha10/speaker"));
506 assert!(NodeResolver::is_framework_node("@mecha10/motor"));
507 assert!(!NodeResolver::is_framework_node("my-custom-node"));
508 assert!(!NodeResolver::is_framework_node("speaker"));
509 }
510
511 #[test]
512 fn test_short_name() {
513 assert_eq!(NodeResolver::short_name("@mecha10/speaker"), Some("speaker"));
514 assert_eq!(NodeResolver::short_name("@mecha10/motor"), Some("motor"));
515 assert_eq!(NodeResolver::short_name("my-custom-node"), None);
516 }
517
518 #[test]
519 fn test_binary_name() {
520 let resolver = NodeResolver {
521 cache_dir: PathBuf::from("/tmp"),
522 version: "0.1.44".to_string(),
523 target: "aarch64-unknown-linux-gnu".to_string(),
524 };
525
526 assert_eq!(
527 resolver.binary_name("speaker"),
528 "speaker-0.1.44-aarch64-unknown-linux-gnu"
529 );
530 }
531}