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!("{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        // Also chmod npx if present
300        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    // ── uv download ──────────────────────────────────────────────────────
309
310    /// Download and extract `uv` for `platform`.
311    ///
312    /// Returns the path to the `uv` binary.
313    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    // ── Private helpers ──────────────────────────────────────────────────
377
378    /// Determine the (base_name, version) pair for a RuntimeType.
379    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    /// Map our platform string to Node.js distribution (os, arch).
394    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    /// Map our platform string to a uv Rust target triple.
407    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    /// Recursively find a named executable under `dir`.
446    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(&current).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        // Remove macOS quarantine
488        #[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    // Blocking extraction helpers (called via spawn_blocking) ─────────────
501
502    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}