Skip to main content

routa_core/acp/
runtime_manager.rs

1//! ACP Runtime Manager — Downloads and manages Node.js and uv runtimes.
2//!
3//! Mirrors the Kotlin `AcpRuntimeManager` from the IntelliJ plugin.
4//! Responsibilities:
5//!   - Detect system-installed runtimes (node, npx, uv, uvx) via PATH
6//!   - Download and cache managed runtimes in `{data_dir}/acp-agents/.runtimes/`
7//!   - Platform detection and URL construction
8//!
9//! Runtime resolution priority (per RuntimeType):
10//!   1. getManagedRuntime()  — check .runtimes/{node|uv}/{version}/
11//!   2. getSystemRuntime()   — search system PATH
12//!   3. ensureRuntime()      — auto-download when neither is available
13//!
14//! NPX/UVX mapping:
15//!   - RuntimeType::Npx  → download Node.js, then find `npx` in the same dir
16//!   - RuntimeType::Uvx  → download uv,      then find `uvx` in the same dir
17
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21
22use tokio::sync::Mutex;
23
24use super::paths::AcpPaths;
25
26// ─── Platform Constants ────────────────────────────────────────────────────
27
28pub 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
35/// Return the current platform string (e.g. `"darwin-aarch64"`).
36pub 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, // safe fallback
45    }
46}
47
48// ─── Runtime Type ──────────────────────────────────────────────────────────
49
50/// Which runtime to locate or download.
51#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum RuntimeType {
54    /// The `node` binary itself.
55    Node,
56    /// The `npx` binary that ships with Node.js.
57    Npx,
58    /// The `uv` binary from astral-sh/uv.
59    Uv,
60    /// The `uvx` binary that ships with uv.
61    Uvx,
62}
63
64impl RuntimeType {
65    /// CLI name of the binary.
66    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    /// Return a human-readable label.
76    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// ─── Runtime Info ──────────────────────────────────────────────────────────
87
88/// Resolved information for an available runtime.
89#[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
97// ─── Manager ──────────────────────────────────────────────────────────────
98
99/// Default Node.js version to download when none is found.
100const DEFAULT_NODE_VERSION: &str = "22.12.0";
101
102/// Default uv version to download when none is found.
103const 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
108/// Manages Node.js / uv runtime discovery and auto-download.
109pub struct AcpRuntimeManager {
110    paths: AcpPaths,
111    /// Per-runtime-key download locks to prevent concurrent downloads.
112    download_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
113}
114
115impl AcpRuntimeManager {
116    /// Create a new runtime manager backed by the given `AcpPaths`.
117    pub fn new(paths: AcpPaths) -> Self {
118        Self {
119            paths,
120            download_locks: Arc::new(Mutex::new(HashMap::new())),
121        }
122    }
123
124    // ── Public API ───────────────────────────────────────────────────────
125
126    /// Returns `true` if the runtime is reachable (managed or system).
127    pub async fn is_runtime_available(&self, rt: &RuntimeType) -> bool {
128        self.get_runtime_path(rt).await.is_some()
129    }
130
131    /// Run `{binary} --version` and return the trimmed first line of output.
132    /// Returns `None` if the runtime is not available or the command fails.
133    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    /// Return the best path for a runtime: managed first, then system.
150    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    /// Locate the runtime on the system PATH.
158    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    /// Locate a previously downloaded (managed) runtime.
169    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    /// Ensure the runtime is available, downloading it if necessary.
188    ///
189    /// Returns a `RuntimeInfo` with the resolved path.
190    pub async fn ensure_runtime(&self, rt: &RuntimeType) -> Result<RuntimeInfo, String> {
191        // 1. Managed runtime already present?
192        if let Some(info) = self.get_managed_runtime(rt).await {
193            return Ok(info);
194        }
195        // 2. System runtime available?
196        if let Some(info) = self.get_system_runtime(rt) {
197            return Ok(info);
198        }
199        // 3. Download the base type, then locate the companion executable.
200        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        // For Npx / Uvx we need the companion binary in the same tree.
210        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    // ── Node.js download ─────────────────────────────────────────────────
233
234    /// Download and extract Node.js for `platform`.
235    ///
236    /// Returns the path to the `node` binary.
237    ///
238    /// Concurrent calls for the same version are serialised by a per-key
239    /// mutex and will re-use the already-extracted binary.
240    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        // Already present?
248        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!(
264            "{}/v{}/{}.{}",
265            NODE_DOWNLOAD_BASE, version, archive_base, ext
266        );
267
268        let download_dir = self.paths.downloads_dir().join("node").join(version);
269        tokio::fs::create_dir_all(&download_dir)
270            .await
271            .map_err(|e| format!("mkdir download_dir: {}", e))?;
272        let archive_path = download_dir.join(format!("{}.{}", archive_base, ext));
273
274        tracing::info!(
275            "[AcpRuntimeManager] Downloading Node.js {}: {}",
276            version,
277            url
278        );
279        self.download_file(&url, &archive_path).await?;
280
281        let arc = archive_path.clone();
282        let dir = runtime_dir.clone();
283        tokio::task::spawn_blocking(move || {
284            if arc.to_string_lossy().ends_with(".zip") {
285                Self::extract_zip_sync(&arc, &dir)
286            } else {
287                Self::extract_tgz_sync(&arc, &dir)
288            }
289        })
290        .await
291        .map_err(|e| format!("extract task panicked: {}", e))??;
292
293        let _ = tokio::fs::remove_dir_all(&download_dir).await;
294
295        let node_path = self
296            .find_executable_in(&runtime_dir, "node", is_win)
297            .await
298            .ok_or_else(|| "node binary not found after extraction".to_string())?;
299
300        self.make_executable(&node_path).await?;
301
302        // Also chmod npx if present
303        if let Some(npx) = self.find_executable_in(&runtime_dir, "npx", is_win).await {
304            let _ = self.make_executable(&npx).await;
305        }
306
307        tracing::info!("[AcpRuntimeManager] Node.js ready: {:?}", node_path);
308        Ok(node_path)
309    }
310
311    // ── uv download ──────────────────────────────────────────────────────
312
313    /// Download and extract `uv` for `platform`.
314    ///
315    /// Returns the path to the `uv` binary.
316    pub async fn download_uv(&self, version: &str, platform: &str) -> Result<PathBuf, String> {
317        let lock = self.get_lock(&format!("uv-{}", version)).await;
318        let _guard = lock.lock().await;
319
320        let runtime_dir = self.paths.runtime_dir("uv", version);
321        let is_windows = std::env::consts::OS == "windows";
322
323        if let Some(p) = self
324            .find_executable_in(&runtime_dir, "uv", is_windows)
325            .await
326        {
327            return Ok(p);
328        }
329
330        tokio::fs::create_dir_all(&runtime_dir)
331            .await
332            .map_err(|e| format!("mkdir runtime_dir: {}", e))?;
333
334        let target = Self::uv_target(platform)?;
335        let ext = if is_windows { "zip" } else { "tar.gz" };
336        let archive_base = format!("uv-{}", target);
337        let url = format!("{}/{}/{}.{}", UV_DOWNLOAD_BASE, version, archive_base, ext);
338
339        let download_dir = self.paths.downloads_dir().join("uv").join(version);
340        tokio::fs::create_dir_all(&download_dir)
341            .await
342            .map_err(|e| format!("mkdir download_dir: {}", e))?;
343        let archive_path = download_dir.join(format!("{}.{}", archive_base, ext));
344
345        tracing::info!("[AcpRuntimeManager] Downloading uv {}: {}", version, url);
346        self.download_file(&url, &archive_path).await?;
347
348        let arc = archive_path.clone();
349        let dir = runtime_dir.clone();
350        tokio::task::spawn_blocking(move || {
351            if arc.to_string_lossy().ends_with(".zip") {
352                Self::extract_zip_sync(&arc, &dir)
353            } else {
354                Self::extract_tgz_sync(&arc, &dir)
355            }
356        })
357        .await
358        .map_err(|e| format!("extract task panicked: {}", e))??;
359
360        let _ = tokio::fs::remove_dir_all(&download_dir).await;
361
362        let uv_path = self
363            .find_executable_in(&runtime_dir, "uv", is_windows)
364            .await
365            .ok_or_else(|| "uv binary not found after extraction".to_string())?;
366
367        self.make_executable(&uv_path).await?;
368        if let Some(uvx) = self
369            .find_executable_in(&runtime_dir, "uvx", is_windows)
370            .await
371        {
372            let _ = self.make_executable(&uvx).await;
373        }
374
375        tracing::info!("[AcpRuntimeManager] uv ready: {:?}", uv_path);
376        Ok(uv_path)
377    }
378
379    // ── Private helpers ──────────────────────────────────────────────────
380
381    /// Determine the (base_name, version) pair for a RuntimeType.
382    fn base_and_version(&self, rt: &RuntimeType) -> (&'static str, &'static str) {
383        match rt {
384            RuntimeType::Node | RuntimeType::Npx => ("node", DEFAULT_NODE_VERSION),
385            RuntimeType::Uv | RuntimeType::Uvx => ("uv", DEFAULT_UV_VERSION),
386        }
387    }
388
389    async fn get_lock(&self, key: &str) -> Arc<Mutex<()>> {
390        let mut map = self.download_locks.lock().await;
391        map.entry(key.to_string())
392            .or_insert_with(|| Arc::new(Mutex::new(())))
393            .clone()
394    }
395
396    /// Map our platform string to Node.js distribution (os, arch).
397    fn node_platform(platform: &str) -> Result<(&'static str, &'static str), String> {
398        match platform {
399            DARWIN_AARCH64 => Ok(("darwin", "arm64")),
400            DARWIN_X86_64 => Ok(("darwin", "x64")),
401            LINUX_AARCH64 => Ok(("linux", "arm64")),
402            LINUX_X86_64 => Ok(("linux", "x64")),
403            WINDOWS_AARCH64 => Ok(("win", "arm64")),
404            WINDOWS_X86_64 => Ok(("win", "x64")),
405            other => Err(format!("Unsupported platform for Node.js: {}", other)),
406        }
407    }
408
409    /// Map our platform string to a uv Rust target triple.
410    fn uv_target(platform: &str) -> Result<&'static str, String> {
411        match platform {
412            DARWIN_AARCH64 => Ok("aarch64-apple-darwin"),
413            DARWIN_X86_64 => Ok("x86_64-apple-darwin"),
414            LINUX_AARCH64 => Ok("aarch64-unknown-linux-gnu"),
415            LINUX_X86_64 => Ok("x86_64-unknown-linux-gnu"),
416            WINDOWS_AARCH64 => Ok("aarch64-pc-windows-msvc"),
417            WINDOWS_X86_64 => Ok("x86_64-pc-windows-msvc"),
418            other => Err(format!("Unsupported platform for uv: {}", other)),
419        }
420    }
421
422    async fn download_file(&self, url: &str, dest: &Path) -> Result<(), String> {
423        let resp = reqwest::get(url)
424            .await
425            .map_err(|e| format!("HTTP GET {}: {}", url, e))?;
426
427        if !resp.status().is_success() {
428            return Err(format!("Download failed ({}) for {}", resp.status(), url));
429        }
430
431        let bytes = resp
432            .bytes()
433            .await
434            .map_err(|e| format!("Reading response body: {}", e))?;
435
436        tokio::fs::write(dest, &bytes)
437            .await
438            .map_err(|e| format!("Writing {:?}: {}", dest, e))?;
439
440        tracing::info!(
441            "[AcpRuntimeManager] Downloaded {} bytes → {:?}",
442            bytes.len(),
443            dest
444        );
445        Ok(())
446    }
447
448    /// Recursively find a named executable under `dir`.
449    async fn find_executable_in(
450        &self,
451        dir: &Path,
452        name: &str,
453        is_windows: bool,
454    ) -> Option<PathBuf> {
455        let exe = if is_windows {
456            format!("{}.exe", name)
457        } else {
458            name.to_string()
459        };
460
461        let mut stack = vec![dir.to_path_buf()];
462        while let Some(current) = stack.pop() {
463            let mut rd = tokio::fs::read_dir(&current).await.ok()?;
464            while let Ok(Some(entry)) = rd.next_entry().await {
465                let path = entry.path();
466                if path.is_dir() {
467                    stack.push(path);
468                } else if path.file_name().map(|n| n == exe.as_str()).unwrap_or(false) {
469                    return Some(path);
470                }
471            }
472        }
473        None
474    }
475
476    async fn make_executable(&self, _path: &Path) -> Result<(), String> {
477        #[cfg(unix)]
478        {
479            use std::os::unix::fs::PermissionsExt;
480            let mut perms = tokio::fs::metadata(_path)
481                .await
482                .map_err(|e| format!("metadata {:?}: {}", _path, e))?
483                .permissions();
484            perms.set_mode(perms.mode() | 0o755);
485            tokio::fs::set_permissions(_path, perms)
486                .await
487                .map_err(|e| format!("chmod {:?}: {}", _path, e))?;
488        }
489
490        // Remove macOS quarantine
491        #[cfg(target_os = "macos")]
492        {
493            let s = _path.to_string_lossy().to_string();
494            let _ = tokio::process::Command::new("xattr")
495                .args(["-d", "com.apple.quarantine", &s])
496                .output()
497                .await;
498        }
499
500        Ok(())
501    }
502
503    // Blocking extraction helpers (called via spawn_blocking) ─────────────
504
505    fn extract_zip_sync(archive: &Path, dest: &Path) -> Result<(), String> {
506        let f =
507            std::fs::File::open(archive).map_err(|e| format!("open zip {:?}: {}", archive, e))?;
508        let mut z =
509            zip::ZipArchive::new(f).map_err(|e| format!("read zip {:?}: {}", archive, e))?;
510        for i in 0..z.len() {
511            let mut entry = z
512                .by_index(i)
513                .map_err(|e| format!("zip entry {}: {}", i, e))?;
514            let out = dest.join(entry.mangled_name());
515            if entry.name().ends_with('/') {
516                std::fs::create_dir_all(&out).ok();
517            } else {
518                if let Some(p) = out.parent() {
519                    std::fs::create_dir_all(p).ok();
520                }
521                let mut outf =
522                    std::fs::File::create(&out).map_err(|e| format!("create {:?}: {}", out, e))?;
523                std::io::copy(&mut entry, &mut outf)
524                    .map_err(|e| format!("extract {:?}: {}", out, e))?;
525            }
526        }
527        Ok(())
528    }
529
530    fn extract_tgz_sync(archive: &Path, dest: &Path) -> Result<(), String> {
531        let f = std::fs::File::open(archive)
532            .map_err(|e| format!("open tar.gz {:?}: {}", archive, e))?;
533        let gz = flate2::read::GzDecoder::new(f);
534        tar::Archive::new(gz)
535            .unpack(dest)
536            .map_err(|e| format!("unpack tar.gz {:?}: {}", archive, e))
537    }
538}