1use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21
22use tokio::sync::Mutex;
23
24use super::paths::AcpPaths;
25
26pub const DARWIN_X86_64: &str = "darwin-x86_64";
29pub const DARWIN_AARCH64: &str = "darwin-aarch64";
30pub const LINUX_X86_64: &str = "linux-x86_64";
31pub const LINUX_AARCH64: &str = "linux-aarch64";
32pub const WINDOWS_X86_64: &str = "windows-x86_64";
33pub const WINDOWS_AARCH64: &str = "windows-aarch64";
34
35pub fn current_platform() -> &'static str {
37 match (std::env::consts::OS, std::env::consts::ARCH) {
38 ("macos", "aarch64") => DARWIN_AARCH64,
39 ("macos", "x86_64") => DARWIN_X86_64,
40 ("linux", "aarch64") => LINUX_AARCH64,
41 ("linux", "x86_64") => LINUX_X86_64,
42 ("windows", "aarch64") => WINDOWS_AARCH64,
43 ("windows", "x86_64") => WINDOWS_X86_64,
44 _ => LINUX_X86_64, }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum RuntimeType {
54 Node,
56 Npx,
58 Uv,
60 Uvx,
62}
63
64impl RuntimeType {
65 pub fn command_name(&self) -> &'static str {
67 match self {
68 RuntimeType::Node => "node",
69 RuntimeType::Npx => "npx",
70 RuntimeType::Uv => "uv",
71 RuntimeType::Uvx => "uvx",
72 }
73 }
74
75 pub fn label(&self) -> &'static str {
77 match self {
78 RuntimeType::Node => "Node.js",
79 RuntimeType::Npx => "npx",
80 RuntimeType::Uv => "uv",
81 RuntimeType::Uvx => "uvx",
82 }
83 }
84}
85
86#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
90pub struct RuntimeInfo {
91 pub runtime_type: RuntimeType,
92 pub version: Option<String>,
93 pub path: PathBuf,
94 pub is_managed: bool,
95}
96
97const DEFAULT_NODE_VERSION: &str = "22.12.0";
101
102const DEFAULT_UV_VERSION: &str = "0.5.11";
104
105const NODE_DOWNLOAD_BASE: &str = "https://nodejs.org/dist";
106const UV_DOWNLOAD_BASE: &str = "https://github.com/astral-sh/uv/releases/download";
107
108pub struct AcpRuntimeManager {
110 paths: AcpPaths,
111 download_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
113}
114
115impl AcpRuntimeManager {
116 pub fn new(paths: AcpPaths) -> Self {
118 Self {
119 paths,
120 download_locks: Arc::new(Mutex::new(HashMap::new())),
121 }
122 }
123
124 pub async fn is_runtime_available(&self, rt: &RuntimeType) -> bool {
128 self.get_runtime_path(rt).await.is_some()
129 }
130
131 pub async fn get_version(&self, rt: &RuntimeType) -> Option<String> {
134 let path = self.get_runtime_path(rt).await?;
135 let output = tokio::process::Command::new(&path)
136 .arg("--version")
137 .output()
138 .await
139 .ok()?;
140 let combined = String::from_utf8_lossy(&output.stdout).to_string()
141 + &String::from_utf8_lossy(&output.stderr);
142 combined
143 .lines()
144 .next()
145 .map(|l| l.trim().to_string())
146 .filter(|s| !s.is_empty())
147 }
148
149 pub async fn get_runtime_path(&self, rt: &RuntimeType) -> Option<PathBuf> {
151 if let Some(info) = self.get_managed_runtime(rt).await {
152 return Some(info.path);
153 }
154 self.get_system_runtime(rt).map(|i| i.path)
155 }
156
157 pub fn get_system_runtime(&self, rt: &RuntimeType) -> Option<RuntimeInfo> {
159 let path_str = crate::shell_env::which(rt.command_name())?;
160 Some(RuntimeInfo {
161 runtime_type: rt.clone(),
162 version: None,
163 path: PathBuf::from(path_str),
164 is_managed: false,
165 })
166 }
167
168 pub async fn get_managed_runtime(&self, rt: &RuntimeType) -> Option<RuntimeInfo> {
170 let (base, version) = self.base_and_version(rt);
171 let runtime_dir = self.paths.runtime_dir(base, version);
172 if !runtime_dir.exists() {
173 return None;
174 }
175 let is_windows = std::env::consts::OS == "windows";
176 let exe = self
177 .find_executable_in(&runtime_dir, rt.command_name(), is_windows)
178 .await?;
179 Some(RuntimeInfo {
180 runtime_type: rt.clone(),
181 version: Some(version.to_string()),
182 path: exe,
183 is_managed: true,
184 })
185 }
186
187 pub async fn ensure_runtime(&self, rt: &RuntimeType) -> Result<RuntimeInfo, String> {
191 if let Some(info) = self.get_managed_runtime(rt).await {
193 return Ok(info);
194 }
195 if let Some(info) = self.get_system_runtime(rt) {
197 return Ok(info);
198 }
199 let platform = current_platform();
201 let (base, version) = self.base_and_version(rt);
202
203 let _base_path = match base {
204 "node" => self.download_node(version, platform).await?,
205 "uv" => self.download_uv(version, platform).await?,
206 other => return Err(format!("Unknown runtime base: {other}")),
207 };
208
209 let is_windows = std::env::consts::OS == "windows";
211 let runtime_dir = self.paths.runtime_dir(base, version);
212 let exe = self
213 .find_executable_in(&runtime_dir, rt.command_name(), is_windows)
214 .await
215 .ok_or_else(|| {
216 format!(
217 "'{}' not found after downloading {} (looked in {:?})",
218 rt.command_name(),
219 base,
220 runtime_dir,
221 )
222 })?;
223
224 Ok(RuntimeInfo {
225 runtime_type: rt.clone(),
226 version: Some(version.to_string()),
227 path: exe,
228 is_managed: true,
229 })
230 }
231
232 pub async fn download_node(&self, version: &str, platform: &str) -> Result<PathBuf, String> {
241 let lock = self.get_lock(&format!("node-{version}")).await;
242 let _guard = lock.lock().await;
243
244 let runtime_dir = self.paths.runtime_dir("node", version);
245 let is_windows = std::env::consts::OS == "windows";
246
247 if let Some(p) = self
249 .find_executable_in(&runtime_dir, "node", is_windows)
250 .await
251 {
252 return Ok(p);
253 }
254
255 tokio::fs::create_dir_all(&runtime_dir)
256 .await
257 .map_err(|e| format!("mkdir runtime_dir: {e}"))?;
258
259 let (node_os, node_arch) = Self::node_platform(platform)?;
260 let is_win = node_os == "win";
261 let ext = if is_win { "zip" } else { "tar.gz" };
262 let archive_base = format!("node-v{version}-{node_os}-{node_arch}");
263 let url = format!("{NODE_DOWNLOAD_BASE}/v{version}/{archive_base}.{ext}");
264
265 let download_dir = self.paths.downloads_dir().join("node").join(version);
266 tokio::fs::create_dir_all(&download_dir)
267 .await
268 .map_err(|e| format!("mkdir download_dir: {e}"))?;
269 let archive_path = download_dir.join(format!("{archive_base}.{ext}"));
270
271 tracing::info!(
272 "[AcpRuntimeManager] Downloading Node.js {}: {}",
273 version,
274 url
275 );
276 self.download_file(&url, &archive_path).await?;
277
278 let arc = archive_path.clone();
279 let dir = runtime_dir.clone();
280 tokio::task::spawn_blocking(move || {
281 if arc.to_string_lossy().ends_with(".zip") {
282 Self::extract_zip_sync(&arc, &dir)
283 } else {
284 Self::extract_tgz_sync(&arc, &dir)
285 }
286 })
287 .await
288 .map_err(|e| format!("extract task panicked: {e}"))??;
289
290 let _ = tokio::fs::remove_dir_all(&download_dir).await;
291
292 let node_path = self
293 .find_executable_in(&runtime_dir, "node", is_win)
294 .await
295 .ok_or_else(|| "node binary not found after extraction".to_string())?;
296
297 self.make_executable(&node_path).await?;
298
299 if let Some(npx) = self.find_executable_in(&runtime_dir, "npx", is_win).await {
301 let _ = self.make_executable(&npx).await;
302 }
303
304 tracing::info!("[AcpRuntimeManager] Node.js ready: {:?}", node_path);
305 Ok(node_path)
306 }
307
308 pub async fn download_uv(&self, version: &str, platform: &str) -> Result<PathBuf, String> {
314 let lock = self.get_lock(&format!("uv-{version}")).await;
315 let _guard = lock.lock().await;
316
317 let runtime_dir = self.paths.runtime_dir("uv", version);
318 let is_windows = std::env::consts::OS == "windows";
319
320 if let Some(p) = self
321 .find_executable_in(&runtime_dir, "uv", is_windows)
322 .await
323 {
324 return Ok(p);
325 }
326
327 tokio::fs::create_dir_all(&runtime_dir)
328 .await
329 .map_err(|e| format!("mkdir runtime_dir: {e}"))?;
330
331 let target = Self::uv_target(platform)?;
332 let ext = if is_windows { "zip" } else { "tar.gz" };
333 let archive_base = format!("uv-{target}");
334 let url = format!("{UV_DOWNLOAD_BASE}/{version}/{archive_base}.{ext}");
335
336 let download_dir = self.paths.downloads_dir().join("uv").join(version);
337 tokio::fs::create_dir_all(&download_dir)
338 .await
339 .map_err(|e| format!("mkdir download_dir: {e}"))?;
340 let archive_path = download_dir.join(format!("{archive_base}.{ext}"));
341
342 tracing::info!("[AcpRuntimeManager] Downloading uv {}: {}", version, url);
343 self.download_file(&url, &archive_path).await?;
344
345 let arc = archive_path.clone();
346 let dir = runtime_dir.clone();
347 tokio::task::spawn_blocking(move || {
348 if arc.to_string_lossy().ends_with(".zip") {
349 Self::extract_zip_sync(&arc, &dir)
350 } else {
351 Self::extract_tgz_sync(&arc, &dir)
352 }
353 })
354 .await
355 .map_err(|e| format!("extract task panicked: {e}"))??;
356
357 let _ = tokio::fs::remove_dir_all(&download_dir).await;
358
359 let uv_path = self
360 .find_executable_in(&runtime_dir, "uv", is_windows)
361 .await
362 .ok_or_else(|| "uv binary not found after extraction".to_string())?;
363
364 self.make_executable(&uv_path).await?;
365 if let Some(uvx) = self
366 .find_executable_in(&runtime_dir, "uvx", is_windows)
367 .await
368 {
369 let _ = self.make_executable(&uvx).await;
370 }
371
372 tracing::info!("[AcpRuntimeManager] uv ready: {:?}", uv_path);
373 Ok(uv_path)
374 }
375
376 fn base_and_version(&self, rt: &RuntimeType) -> (&'static str, &'static str) {
380 match rt {
381 RuntimeType::Node | RuntimeType::Npx => ("node", DEFAULT_NODE_VERSION),
382 RuntimeType::Uv | RuntimeType::Uvx => ("uv", DEFAULT_UV_VERSION),
383 }
384 }
385
386 async fn get_lock(&self, key: &str) -> Arc<Mutex<()>> {
387 let mut map = self.download_locks.lock().await;
388 map.entry(key.to_string())
389 .or_insert_with(|| Arc::new(Mutex::new(())))
390 .clone()
391 }
392
393 fn node_platform(platform: &str) -> Result<(&'static str, &'static str), String> {
395 match platform {
396 DARWIN_AARCH64 => Ok(("darwin", "arm64")),
397 DARWIN_X86_64 => Ok(("darwin", "x64")),
398 LINUX_AARCH64 => Ok(("linux", "arm64")),
399 LINUX_X86_64 => Ok(("linux", "x64")),
400 WINDOWS_AARCH64 => Ok(("win", "arm64")),
401 WINDOWS_X86_64 => Ok(("win", "x64")),
402 other => Err(format!("Unsupported platform for Node.js: {other}")),
403 }
404 }
405
406 fn uv_target(platform: &str) -> Result<&'static str, String> {
408 match platform {
409 DARWIN_AARCH64 => Ok("aarch64-apple-darwin"),
410 DARWIN_X86_64 => Ok("x86_64-apple-darwin"),
411 LINUX_AARCH64 => Ok("aarch64-unknown-linux-gnu"),
412 LINUX_X86_64 => Ok("x86_64-unknown-linux-gnu"),
413 WINDOWS_AARCH64 => Ok("aarch64-pc-windows-msvc"),
414 WINDOWS_X86_64 => Ok("x86_64-pc-windows-msvc"),
415 other => Err(format!("Unsupported platform for uv: {other}")),
416 }
417 }
418
419 async fn download_file(&self, url: &str, dest: &Path) -> Result<(), String> {
420 let resp = reqwest::get(url)
421 .await
422 .map_err(|e| format!("HTTP GET {url}: {e}"))?;
423
424 if !resp.status().is_success() {
425 return Err(format!("Download failed ({}) for {}", resp.status(), url));
426 }
427
428 let bytes = resp
429 .bytes()
430 .await
431 .map_err(|e| format!("Reading response body: {e}"))?;
432
433 tokio::fs::write(dest, &bytes)
434 .await
435 .map_err(|e| format!("Writing {dest:?}: {e}"))?;
436
437 tracing::info!(
438 "[AcpRuntimeManager] Downloaded {} bytes → {:?}",
439 bytes.len(),
440 dest
441 );
442 Ok(())
443 }
444
445 async fn find_executable_in(
447 &self,
448 dir: &Path,
449 name: &str,
450 is_windows: bool,
451 ) -> Option<PathBuf> {
452 let exe = if is_windows {
453 format!("{name}.exe")
454 } else {
455 name.to_string()
456 };
457
458 let mut stack = vec![dir.to_path_buf()];
459 while let Some(current) = stack.pop() {
460 let mut rd = tokio::fs::read_dir(¤t).await.ok()?;
461 while let Ok(Some(entry)) = rd.next_entry().await {
462 let path = entry.path();
463 if path.is_dir() {
464 stack.push(path);
465 } else if path.file_name().map(|n| n == exe.as_str()).unwrap_or(false) {
466 return Some(path);
467 }
468 }
469 }
470 None
471 }
472
473 async fn make_executable(&self, _path: &Path) -> Result<(), String> {
474 #[cfg(unix)]
475 {
476 use std::os::unix::fs::PermissionsExt;
477 let mut perms = tokio::fs::metadata(_path)
478 .await
479 .map_err(|e| format!("metadata {_path:?}: {e}"))?
480 .permissions();
481 perms.set_mode(perms.mode() | 0o755);
482 tokio::fs::set_permissions(_path, perms)
483 .await
484 .map_err(|e| format!("chmod {_path:?}: {e}"))?;
485 }
486
487 #[cfg(target_os = "macos")]
489 {
490 let s = _path.to_string_lossy().to_string();
491 let _ = tokio::process::Command::new("xattr")
492 .args(["-d", "com.apple.quarantine", &s])
493 .output()
494 .await;
495 }
496
497 Ok(())
498 }
499
500 fn extract_zip_sync(archive: &Path, dest: &Path) -> Result<(), String> {
503 let f = std::fs::File::open(archive).map_err(|e| format!("open zip {archive:?}: {e}"))?;
504 let mut z = zip::ZipArchive::new(f).map_err(|e| format!("read zip {archive:?}: {e}"))?;
505 for i in 0..z.len() {
506 let mut entry = z.by_index(i).map_err(|e| format!("zip entry {i}: {e}"))?;
507 let out = dest.join(entry.mangled_name());
508 if entry.name().ends_with('/') {
509 std::fs::create_dir_all(&out).ok();
510 } else {
511 if let Some(p) = out.parent() {
512 std::fs::create_dir_all(p).ok();
513 }
514 let mut outf =
515 std::fs::File::create(&out).map_err(|e| format!("create {out:?}: {e}"))?;
516 std::io::copy(&mut entry, &mut outf)
517 .map_err(|e| format!("extract {out:?}: {e}"))?;
518 }
519 }
520 Ok(())
521 }
522
523 fn extract_tgz_sync(archive: &Path, dest: &Path) -> Result<(), String> {
524 let f =
525 std::fs::File::open(archive).map_err(|e| format!("open tar.gz {archive:?}: {e}"))?;
526 let gz = flate2::read::GzDecoder::new(f);
527 tar::Archive::new(gz)
528 .unpack(dest)
529 .map_err(|e| format!("unpack tar.gz {archive:?}: {e}"))
530 }
531}