diff --git a/src/app/lsp/provisioner.rs b/src/app/lsp/provisioner.rs index e5c673f..0105289 100644 --- a/src/app/lsp/provisioner.rs +++ b/src/app/lsp/provisioner.rs @@ -12,7 +12,7 @@ //! user-friendly path first (rustup component, npm global, etc.) and //! only fall back to package managers or manual download if those fail. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -51,6 +51,12 @@ pub enum ProvisionResult { }, } +/// Sentinel command names used by `provision_single` to detect "download" +/// tiers (which are dispatched to `download_*` helpers rather than +/// `run_command`). Kept as constants so `supported_servers` stays readable. +const DOWNLOAD_RUST_BIN: &str = "__download_rust_analyzer__"; +const DOWNLOAD_JDTLS: &str = "__download_jdtls__"; + /// Static description of a single language server: how to detect it, /// what file extensions it handles, and how to install it. #[derive(Debug, Clone)] @@ -93,13 +99,20 @@ pub struct InstallTier { /// computed at startup (compile time would also work, but keeping the /// shape uniform with the rest of the struct makes the call sites tidy). #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct EnvInfo { pub has_rustup: bool, pub has_npm: bool, pub has_go: bool, pub has_java: bool, + pub has_cargo: bool, + pub has_curl: bool, + pub has_wget: bool, + pub has_tar: bool, + pub has_pacman: bool, pub has_apt: bool, pub has_brew: bool, + pub has_dnf: bool, pub is_linux: bool, pub is_macos: bool, } @@ -144,8 +157,14 @@ pub fn detect_env() -> EnvInfo { has_npm: which("npm").is_some(), has_go: which("go").is_some(), has_java: which("java").is_some(), + has_cargo: which("cargo").is_some(), + has_curl: which("curl").is_some(), + has_wget: which("wget").is_some(), + has_tar: which("tar").is_some(), + has_pacman: which("pacman").is_some(), has_apt: which("apt").is_some() || which("apt-get").is_some(), has_brew: which("brew").is_some(), + has_dnf: which("dnf").is_some(), is_linux: cfg!(target_os = "linux"), is_macos: cfg!(target_os = "macos"), } @@ -168,16 +187,38 @@ pub fn supported_servers() -> Vec { language: "rust".to_string(), extensions: vec![".rs".to_string()], binary_names: vec!["rust-analyzer".to_string()], - install_tiers: vec![InstallTier { - label: "rustup component".to_string(), - requires: vec!["rustup".to_string()], - command: "rustup".to_string(), - args: vec![ - "component".to_string(), - "add".to_string(), - "rust-analyzer".to_string(), - ], - }], + install_tiers: vec![ + InstallTier { + label: "rustup component".to_string(), + requires: vec!["rustup".to_string()], + command: "rustup".to_string(), + args: vec!["component".to_string(), "add".to_string(), "rust-analyzer".to_string()], + }, + InstallTier { + label: "pacman".to_string(), + requires: vec!["pacman".to_string()], + command: "pacman".to_string(), + args: vec!["-S".to_string(), "--noconfirm".to_string(), "--needed".to_string(), "rust-analyzer".to_string()], + }, + InstallTier { + label: "brew".to_string(), + requires: vec!["brew".to_string()], + command: "brew".to_string(), + args: vec!["install".to_string(), "rust-analyzer".to_string()], + }, + InstallTier { + label: "cargo install".to_string(), + requires: vec!["cargo".to_string()], + command: "cargo".to_string(), + args: vec!["install".to_string(), "--locked".to_string(), "rust-analyzer".to_string()], + }, + InstallTier { + label: "download prebuilt".to_string(), + requires: vec!["curl".to_string(), "tar".to_string()], + command: DOWNLOAD_RUST_BIN.to_string(), + args: vec![], + }, + ], }, LanguageServerDef { name: "typescript-language-server".to_string(), @@ -220,17 +261,19 @@ pub fn supported_servers() -> Vec { name: "jdtls".to_string(), language: "java".to_string(), extensions: vec![".java".to_string()], - binary_names: vec!["jdtls".to_string(), "eclipse-jdt-ls".to_string()], + binary_names: vec!["jdtls".to_string(), "eclipse-jdt-ls".to_string(), "jdtls-launcher".to_string()], install_tiers: vec![ + InstallTier { + label: "pacman".to_string(), + requires: vec!["java".to_string(), "pacman".to_string()], + command: "pacman".to_string(), + args: vec!["-S".to_string(), "--noconfirm".to_string(), "--needed".to_string(), "eclipse-jdt-ls".to_string()], + }, InstallTier { label: "apt".to_string(), requires: vec!["java".to_string(), "apt".to_string()], - command: "apt".to_string(), - args: vec![ - "install".to_string(), - "-y".to_string(), - "eclipse-jdt-ls".to_string(), - ], + command: "sudo".to_string(), + args: vec!["apt".to_string(), "install".to_string(), "-y".to_string(), "eclipse-jdt-ls".to_string()], }, InstallTier { label: "brew".to_string(), @@ -239,9 +282,9 @@ pub fn supported_servers() -> Vec { args: vec!["install".to_string(), "jdtls".to_string()], }, InstallTier { - label: "manual download".to_string(), - requires: vec!["java".to_string()], - command: "__jdtls_download__".to_string(), + label: "download from eclipse".to_string(), + requires: vec!["java".to_string(), "curl".to_string(), "tar".to_string()], + command: DOWNLOAD_JDTLS.to_string(), args: vec![], }, ], @@ -286,7 +329,7 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> }) }); - let timeout = Duration::from_secs(120); + let timeout = Duration::from_secs(180); let start = Instant::now(); let status = loop { match child.try_wait()? { @@ -319,74 +362,157 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> } } -/// Manual-install fallback for jdtls when apt and brew both fail (or -/// the host has neither): create the install directory and a launcher -/// script at `~/.local/share/zesdex/lsp/jdtls/jdtls-launcher.sh`. -/// -/// Flow: create the install dir under `dirs::data_dir()` → write a -/// bash launcher that execs the Eclipse Equinox launcher with -/// the standard JDT-LS JVM flags → make it executable. -/// -/// Returns the absolute path of the launcher script on success. -fn install_jdtls_manual() -> std::io::Result { +/// Resolve the directory where downloaded LSP binaries are stored. +fn lsp_install_dir(server: &str) -> Result { let base = dirs::data_dir() - .unwrap_or_else(|| PathBuf::from(".")) + .ok_or_else(|| "cannot find data directory via dirs crate".to_string())? .join("zesdex") .join("lsp") - .join("jdtls"); - std::fs::create_dir_all(&base)?; + .join(server); + Ok(base) +} - let launcher = base.join("jdtls-launcher.sh"); - let launcher_script = r#"#!/usr/bin/env bash -# Auto-generated launcher for Eclipse JDT-LS, created by zesdex provisioner. -# Adjust JAVA_HOME and the plugin path below if your layout differs. +/// Download a file from `url` to `dest` using curl. +fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> { + let path_str = dest.to_str().ok_or("invalid dest path")?.to_string(); + info!(url = url, dest = %path_str, "downloading"); + let args = [ + "-fsSL", + "--connect-timeout", "15", + "--max-time", &max_secs.to_string(), + "-o", &path_str, + url, + ]; + let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {}", e))?; + if !ok { + return Err(format!("download failed: {}", out.trim())); + } + Ok(()) +} + +/// Download rust-analyzer from GitHub releases and install into +/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`. +fn install_rust_analyzer_binary(env: &EnvInfo) -> Result { + let base = lsp_install_dir("rust-analyzer")?; + std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?; + + let url = if env.is_linux { + "https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz" + } else if env.is_macos { + "https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-aarch64-apple-darwin.gz" + } else { + return Err("no prebuilt binary for this OS".to_string()); + }; + + let gz = base.join("rust-analyzer.gz"); + let target = base.join("rust-analyzer"); + + download_url(url, &gz, 120)?; + let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()]) + .map_err(|e| format!("gunzip spawn: {}", e))?; + if !ok { + return Err(format!("gunzip: {}", out.trim())); + } + + if !target.exists() { + return Err("binary missing after decompression".to_string()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("chmod: {}", e))?; + } + Ok(target) +} + +/// Download Eclipse JDT-LS from the official snapshot server, extract it, +/// and create a launcher script at `bin/jdtls`. +fn install_jdtls_from_eclipse() -> Result { + let base = lsp_install_dir("jdtls")?; + std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?; + + let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz"; + let tarball = base.join("jdtls.tar.gz"); + download_url(url, &tarball, 300)?; + + let (ok, out) = run_command("tar", &[ + "-xzf", tarball.to_str().unwrap_or(""), + "-C", base.to_str().unwrap_or("."), + ]).map_err(|e| format!("tar spawn: {}", e))?; + if !ok { + return Err(format!("tar: {}", out.trim())); + } + let _ = std::fs::remove_file(&tarball); + + if !base.join("plugins").exists() { + return Err("extracted archive missing plugins/ directory".to_string()); + } + + let bin_dir = base.join("bin"); + std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {}", e))?; + let launcher = bin_dir.join("jdtls"); + + let script = r#"#!/usr/bin/env bash set -e -JDTLS_HOME="$(cd "$(dirname "$0")" && pwd)" +JDTLS_HOME="$(cd "$(dirname "$0")/.." && pwd)" +LAUNCHER=$(ls "${JDTLS_HOME}/plugins/org.eclipse.equinox.launcher_"*.jar 2>/dev/null | head -n1) +CONFIG=$(ls -d "${JDTLS_HOME}"/config_* 2>/dev/null | head -n1) +WORKSPACE="${JDTLS_HOME}/workspace" +mkdir -p "${WORKSPACE}" exec java \ -Declipse.application=org.eclipse.jdt.ls.core.id1 \ -Dosgi.bundles.defaultStartLevel=5 \ -Declipse.product=org.eclipse.jdt.ls.core.product \ - -Dlog.level=WARN \ - -noverify \ - -Xmx1G \ - -jar "${JDTLS_HOME}/plugins/org.eclipse.equinox.launcher_"*.jar \ - -configuration "${JDTLS_HOME}/config_"* \ - -data "${JDTLS_HOME}/workspace" \ + -Dlog.level=WARN -noverify -Xmx1G \ + -jar "${LAUNCHER}" -configuration "${CONFIG}" -data "${WORKSPACE}" \ --add-modules=ALL-SYSTEM \ --add-opens java.base/java.util=ALL-UNNAMED \ --add-opens java.base/java.lang=ALL-UNNAMED \ "$@" "#; - std::fs::write(&launcher, launcher_script)?; - + std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {}", e))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&launcher)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&launcher, perms)?; + std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("chmod launcher: {}", e))?; } - Ok(launcher) } +/// Dispatch a sentinel download tier to the correct helper. +fn run_download_tier(name: &str, env: &EnvInfo) -> Result { + match name { + DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env), + DOWNLOAD_JDTLS => install_jdtls_from_eclipse(), + other => Err(format!("unknown download tier '{}'", other)), + } +} + /// Render the "install by hand" message shown to the user when every /// automated tier fails. fn manual_instructions(def: &LanguageServerDef) -> String { match def.language.as_str() { - "rust" => "Install rustup from https://rustup.rs, then run:\n \ - rustup component add rust-analyzer" + "rust" => "Install rust-analyzer:\n \ + Arch: sudo pacman -S rust-analyzer\n \ + macOS: brew install rust-analyzer\n \ + Any: cargo install --locked rust-analyzer\n \ + Rustup: rustup component add rust-analyzer" .to_string(), - "typescript" => "Install Node.js from https://nodejs.org, then run:\n \ - npm install -g typescript typescript-language-server" + "typescript" => "Install typescript-language-server:\n \ + npm install -g typescript typescript-language-server\n \ + Arch: sudo pacman -S typescript-language-server" .to_string(), - "go" => "Install Go from https://go.dev/dl, then run:\n \ - go install golang.org/x/tools/gopls@latest" + "go" => "Install gopls:\n \ + go install golang.org/x/tools/gopls@latest\n \ + Arch: sudo pacman -S gopls" .to_string(), - "java" => "Install Eclipse JDT-LS for your platform:\n \ - Debian/Ubuntu: sudo apt install -y eclipse-jdt-ls\n \ - macOS: brew install jdtls\n \ - Other: see https://.eclipse.org/jdtls/#download" + "java" => "Install Eclipse JDT-LS:\n \ + Arch: sudo pacman -S eclipse-jdt-ls\n \ + Debian: sudo apt install eclipse-jdt-ls\n \ + macOS: brew install jdtls\n \ + Other: see https://.eclipse.org/jdtls/#download" .to_string(), _ => format!("No automated install available for '{}'.", def.language), } @@ -446,35 +572,23 @@ pub fn provision_single(def: &LanguageServerDef, env: &EnvInfo) -> ProvisionResu continue; } - // Special-case jdtls tier 3: no shell command, do it inline. - if tier.command == "__jdtls_download__" { - match install_jdtls_manual() { + // If command is a download sentinel, dispatch to helper. + if tier.command.starts_with("__download_") && tier.command.ends_with("__") { + match run_download_tier(&tier.command, env) { Ok(path) => { - // Prefer the known binary name on PATH; fall back to - // the launcher script we just wrote. - let found = def - .binary_names - .iter() - .find_map(|b| which(b).map(|p| p.to_string_lossy().to_string())) - .unwrap_or_else(|| path.to_string_lossy().to_string()); - + info!(server = %def.name, tier = %tier.label, binary = %path.display(), "download install succeeded"); return ProvisionResult::Installed { server_name: def.name.clone(), language: def.language.clone(), - binary_path: found, + binary_path: path.to_string_lossy().to_string(), }; } Err(e) => { - last_reason = format!("jdtls manual install failed: {}", e); - warn!( - server = %def.name, - tier = %tier.label, - error = %e, - "manual install failed" - ); + last_reason = format!("tier '{}' failed: {}", tier.label, e); + warn!(server = %def.name, tier = %tier.label, error = %e, "download install failed"); + continue; } } - continue; } // Normal tier: shell out. @@ -552,10 +666,16 @@ pub fn provision_all() -> Vec { linux = env.is_linux, macos = env.is_macos, rustup = env.has_rustup, + cargo = env.has_cargo, npm = env.has_npm, go = env.has_go, + java = env.has_java, + curl = env.has_curl, + tar = env.has_tar, + pacman = env.has_pacman, apt = env.has_apt, brew = env.has_brew, + dnf = env.has_dnf, "starting LSP provisioning" ); supported_servers() diff --git a/src/service/provider.rs b/src/service/provider.rs index c43b59a..f065879 100644 --- a/src/service/provider.rs +++ b/src/service/provider.rs @@ -11,7 +11,7 @@ use crate::dto::provider::request::{ChatRequest, StreamOptions, ToolDef}; pub(crate) const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1"; const DEFAULT_MODEL: &str = "deepseek-v4-flash-free"; -pub const DEFAULT_API_KEY: &str = "sk-5dd268d88adb496b-818beb-6bc7498e"; +pub const DEFAULT_API_KEY: &str = ""; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);