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!("Pre-built binary not available for {} on {}", node_name, self.target);
186 }
187
188 Err(anyhow::anyhow!(
189 "Could not resolve binary for node '{}'. \
190 Pre-built binary not available for target '{}'.",
191 node_name,
192 self.target
193 ))
194 }
195
196 pub async fn spawn(&self, node_name: &str, env: HashMap<String, String>) -> Result<Child> {
207 let binary_path = self.resolve(node_name).await?;
208
209 tracing::info!("Spawning node {} from {}", node_name, binary_path.display());
210
211 let child = Command::new(&binary_path)
212 .envs(env)
213 .stdout(Stdio::piped())
214 .stderr(Stdio::piped())
215 .spawn()
216 .with_context(|| format!("Failed to spawn node {}", node_name))?;
217
218 Ok(child)
219 }
220
221 pub async fn spawn_and_wait(&self, node_name: &str, env: HashMap<String, String>) -> Result<()> {
223 let binary_path = self.resolve(node_name).await?;
224
225 tracing::info!("Running node {} from {}", node_name, binary_path.display());
226
227 let status = Command::new(&binary_path)
228 .envs(env)
229 .status()
230 .await
231 .with_context(|| format!("Failed to run node {}", node_name))?;
232
233 if !status.success() {
234 return Err(anyhow::anyhow!("Node {} exited with status: {}", node_name, status));
235 }
236
237 Ok(())
238 }
239
240 fn find_cached(&self, node_name: &str) -> Option<PathBuf> {
242 let binary_name = self.binary_name(node_name);
243 let path = self.cache_dir.join(&binary_name);
244
245 if path.exists() && path.is_file() {
246 #[cfg(unix)]
247 {
248 use std::os::unix::fs::PermissionsExt;
249 if let Ok(metadata) = path.metadata() {
250 if metadata.permissions().mode() & 0o111 != 0 {
251 return Some(path);
252 }
253 }
254 }
255
256 #[cfg(not(unix))]
257 {
258 return Some(path);
259 }
260 }
261
262 let symlink_path = self.cache_dir.join(node_name);
264 if symlink_path.exists() {
265 if let Ok(resolved) = std::fs::canonicalize(&symlink_path) {
266 if resolved.exists() {
267 return Some(resolved);
268 }
269 }
270 }
271
272 None
273 }
274
275 fn binary_name(&self, node_name: &str) -> String {
277 format!("{}-{}-{}", node_name, self.version, self.target)
278 }
279
280 fn tarball_name(&self, node_name: &str) -> String {
282 format!("{}-{}-{}.tar.gz", node_name, self.version, self.target)
283 }
284
285 async fn download(&self, node_name: &str) -> Result<PathBuf> {
287 let client = reqwest::Client::builder()
288 .user_agent("mecha10-node-resolver")
289 .build()
290 .context("Failed to build HTTP client")?;
291
292 let tarball_name = self.tarball_name(node_name);
293 let release_tag = format!("v{}", self.version);
294
295 let release_url = format!(
297 "https://api.github.com/repos/{}/releases/tags/{}",
298 GITHUB_REPO, release_tag
299 );
300
301 tracing::info!("Downloading {} (v{})...", node_name, self.version);
302
303 let response = client
304 .get(&release_url)
305 .send()
306 .await
307 .context("Failed to fetch release info from GitHub")?;
308
309 if !response.status().is_success() {
310 let status = response.status();
311 if status.as_u16() == 404 {
312 return Err(anyhow::anyhow!(
313 "Release v{} not found. Node binaries may not be published yet.",
314 self.version
315 ));
316 }
317 return Err(anyhow::anyhow!("Failed to get release info: HTTP {}", status));
318 }
319
320 let release: serde_json::Value = response.json().await?;
321
322 let assets = release["assets"]
324 .as_array()
325 .ok_or_else(|| anyhow::anyhow!("No assets in release"))?;
326
327 let asset = assets
328 .iter()
329 .find(|a| a["name"].as_str().map(|n| n == tarball_name).unwrap_or(false))
330 .ok_or_else(|| anyhow::anyhow!("Binary '{}' not found in release v{}.", tarball_name, self.version))?;
331
332 let download_url = asset["browser_download_url"]
333 .as_str()
334 .ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?;
335
336 let response = client
338 .get(download_url)
339 .send()
340 .await
341 .context("Failed to download binary")?;
342
343 if !response.status().is_success() {
344 return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
345 }
346
347 let temp_dir = tempdir()?;
349 let temp_file = temp_dir.path().join("binary.tar.gz");
350
351 let bytes = response.bytes().await?;
352 tokio::fs::write(&temp_file, &bytes).await?;
353
354 tokio::fs::create_dir_all(&self.cache_dir).await?;
356
357 let tar_gz = std::fs::File::open(&temp_file)?;
359 let tar = flate2::read::GzDecoder::new(tar_gz);
360 let mut archive = tar::Archive::new(tar);
361
362 let extract_dir = temp_dir.path().join("extract");
363 std::fs::create_dir_all(&extract_dir)?;
364 archive.unpack(&extract_dir)?;
365
366 let extracted_binary = extract_dir.join(node_name);
368 if !extracted_binary.exists() {
369 return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
370 }
371
372 let binary_name = self.binary_name(node_name);
374 let dest_path = self.cache_dir.join(&binary_name);
375
376 tokio::fs::copy(&extracted_binary, &dest_path).await?;
377
378 #[cfg(unix)]
380 {
381 use std::os::unix::fs::PermissionsExt;
382 let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
383 perms.set_mode(0o755);
384 tokio::fs::set_permissions(&dest_path, perms).await?;
385 }
386
387 let symlink_path = self.cache_dir.join(node_name);
389 if symlink_path.exists() || symlink_path.is_symlink() {
390 tokio::fs::remove_file(&symlink_path).await.ok();
391 }
392
393 #[cfg(unix)]
394 {
395 std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
396 }
397
398 tracing::info!("Installed {} to {}", node_name, dest_path.display());
399
400 Ok(dest_path)
401 }
402
403 pub async fn resolve_all(&self, nodes: &[String]) -> Result<HashMap<String, PathBuf>> {
405 use futures_util::future::join_all;
406
407 let framework_nodes: Vec<_> = nodes
408 .iter()
409 .filter(|n| Self::is_framework_node(n))
410 .filter_map(|n| Self::short_name(n).map(|s| s.to_string()))
411 .collect();
412
413 if framework_nodes.is_empty() {
414 return Ok(HashMap::new());
415 }
416
417 tracing::info!("Resolving {} framework nodes...", framework_nodes.len());
418
419 let futures = framework_nodes.iter().map(|name| {
420 let resolver = self.clone();
421 let name = name.clone();
422 async move {
423 let result = resolver.resolve(&name).await;
424 (name, result)
425 }
426 });
427
428 let results = join_all(futures).await;
429
430 let mut resolved = HashMap::new();
431 for (name, result) in results {
432 match result {
433 Ok(path) => {
434 resolved.insert(name, path);
435 }
436 Err(e) => {
437 return Err(anyhow::anyhow!("Failed to resolve node '{}': {}", name, e));
438 }
439 }
440 }
441
442 tracing::info!("All {} framework nodes ready", resolved.len());
443 Ok(resolved)
444 }
445}
446
447impl Default for NodeResolver {
448 fn default() -> Self {
449 Self::new().expect("Failed to create NodeResolver")
450 }
451}
452
453struct TempDir {
455 path: PathBuf,
456}
457
458impl TempDir {
459 fn path(&self) -> &std::path::Path {
460 &self.path
461 }
462}
463
464impl Drop for TempDir {
465 fn drop(&mut self) {
466 let _ = std::fs::remove_dir_all(&self.path);
467 }
468}
469
470fn tempdir() -> std::io::Result<TempDir> {
471 let mut path = std::env::temp_dir();
472 path.push(format!("mecha10-node-resolver-{}-{}", std::process::id(), rand_u64()));
473 std::fs::create_dir_all(&path)?;
474 Ok(TempDir { path })
475}
476
477fn rand_u64() -> u64 {
478 use std::time::{SystemTime, UNIX_EPOCH};
479 SystemTime::now()
480 .duration_since(UNIX_EPOCH)
481 .map(|d| d.as_nanos() as u64)
482 .unwrap_or(0)
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn test_is_framework_node() {
491 assert!(NodeResolver::is_framework_node("@mecha10/speaker"));
492 assert!(NodeResolver::is_framework_node("@mecha10/motor"));
493 assert!(!NodeResolver::is_framework_node("my-custom-node"));
494 assert!(!NodeResolver::is_framework_node("speaker"));
495 }
496
497 #[test]
498 fn test_short_name() {
499 assert_eq!(NodeResolver::short_name("@mecha10/speaker"), Some("speaker"));
500 assert_eq!(NodeResolver::short_name("@mecha10/motor"), Some("motor"));
501 assert_eq!(NodeResolver::short_name("my-custom-node"), None);
502 }
503
504 #[test]
505 fn test_binary_name() {
506 let resolver = NodeResolver {
507 cache_dir: PathBuf::from("/tmp"),
508 version: "0.1.44".to_string(),
509 target: "aarch64-unknown-linux-gnu".to_string(),
510 };
511
512 assert_eq!(
513 resolver.binary_name("speaker"),
514 "speaker-0.1.44-aarch64-unknown-linux-gnu"
515 );
516 }
517}