diff --git a/build.rs b/build.rs index 0c7da4abb..01afed645 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,110 @@ +use std::path::Path; +use std::process::Command; + fn main() { - let dir = std::path::Path::new("web/dist"); - if !dir.exists() { - std::fs::create_dir_all(dir).expect("failed to create web/dist/"); + let dist_dir = Path::new("web/dist"); + let web_dir = Path::new("web"); + + // Tell Cargo to re-run this script when web source files change. + println!("cargo:rerun-if-changed=web/src"); + println!("cargo:rerun-if-changed=web/index.html"); + println!("cargo:rerun-if-changed=web/package.json"); + println!("cargo:rerun-if-changed=web/vite.config.ts"); + + // Attempt to build the web frontend if npm is available and web/dist is + // missing or stale. The build is best-effort: when Node.js is not + // installed (e.g. CI containers, cross-compilation, minimal dev setups) + // we fall back to the existing stub/empty dist directory so the Rust + // build still succeeds. + let needs_build = !dist_dir.join("index.html").exists(); + + if needs_build && web_dir.join("package.json").exists() { + if let Ok(npm) = which_npm() { + eprintln!("cargo:warning=Building web frontend (web/dist is missing or stale)..."); + + // npm ci / npm install + let install_status = Command::new(&npm) + .args(["ci", "--ignore-scripts"]) + .current_dir(web_dir) + .status(); + + match install_status { + Ok(s) if s.success() => {} + Ok(s) => { + // Fall back to `npm install` if `npm ci` fails (no lockfile, etc.) + eprintln!("cargo:warning=npm ci exited with {s}, trying npm install..."); + let fallback = Command::new(&npm) + .args(["install"]) + .current_dir(web_dir) + .status(); + if !matches!(fallback, Ok(s) if s.success()) { + eprintln!("cargo:warning=npm install failed — skipping web build"); + ensure_dist_dir(dist_dir); + return; + } + } + Err(e) => { + eprintln!("cargo:warning=Could not run npm: {e} — skipping web build"); + ensure_dist_dir(dist_dir); + return; + } + } + + // npm run build + let build_status = Command::new(&npm) + .args(["run", "build"]) + .current_dir(web_dir) + .status(); + + match build_status { + Ok(s) if s.success() => { + eprintln!("cargo:warning=Web frontend built successfully."); + } + Ok(s) => { + eprintln!( + "cargo:warning=npm run build exited with {s} — web dashboard may be unavailable" + ); + } + Err(e) => { + eprintln!( + "cargo:warning=Could not run npm build: {e} — web dashboard may be unavailable" + ); + } + } + } + } + + ensure_dist_dir(dist_dir); +} + +/// Ensure the dist directory exists so `rust-embed` does not fail at compile +/// time even when the web frontend is not built. +fn ensure_dist_dir(dist_dir: &Path) { + if !dist_dir.exists() { + std::fs::create_dir_all(dist_dir).expect("failed to create web/dist/"); } } + +/// Locate the `npm` binary on the system PATH. +fn which_npm() -> Result { + let cmd = if cfg!(target_os = "windows") { + "where" + } else { + "which" + }; + + Command::new(cmd) + .arg("npm") + .output() + .ok() + .and_then(|output| { + if output.status.success() { + String::from_utf8(output.stdout) + .ok() + .map(|s| s.lines().next().unwrap_or("npm").trim().to_string()) + } else { + None + } + }) + .ok_or(()) +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index bdc472485..fba9e8350 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -2940,4 +2940,78 @@ mod tests { let err = require_localhost(&peer).unwrap_err(); assert_eq!(err.0, StatusCode::FORBIDDEN); } + + #[tokio::test] + async fn health_endpoint_exposes_require_pairing_false() { + let state = AppState { + config: Arc::new(Mutex::new(Config::default())), + provider: Arc::new(MockProvider::default()), + model: "test-model".into(), + temperature: 0.0, + mem: Arc::new(MockMemory), + auto_save: false, + webhook_secret_hash: None, + pairing: Arc::new(PairingGuard::new(false, &[])), + trust_forwarded_headers: false, + rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)), + idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300), 1000)), + whatsapp: None, + whatsapp_app_secret: None, + linq: None, + linq_signing_secret: None, + nextcloud_talk: None, + nextcloud_talk_webhook_secret: None, + wati: None, + observer: Arc::new(crate::observability::NoopObserver), + tools_registry: Arc::new(Vec::new()), + cost_tracker: None, + event_tx: tokio::sync::broadcast::channel(16).0, + shutdown_tx: tokio::sync::watch::channel(false).0, + }; + + let response = handle_health(State(state)).await.into_response(); + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(parsed["status"], "ok"); + assert_eq!(parsed["require_pairing"], false); + } + + #[tokio::test] + async fn health_endpoint_exposes_require_pairing_true() { + let state = AppState { + config: Arc::new(Mutex::new(Config::default())), + provider: Arc::new(MockProvider::default()), + model: "test-model".into(), + temperature: 0.0, + mem: Arc::new(MockMemory), + auto_save: false, + webhook_secret_hash: None, + pairing: Arc::new(PairingGuard::new(true, &[])), + trust_forwarded_headers: false, + rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)), + idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300), 1000)), + whatsapp: None, + whatsapp_app_secret: None, + linq: None, + linq_signing_secret: None, + nextcloud_talk: None, + nextcloud_talk_webhook_secret: None, + wati: None, + observer: Arc::new(crate::observability::NoopObserver), + tools_registry: Arc::new(Vec::new()), + cost_tracker: None, + event_tx: tokio::sync::broadcast::channel(16).0, + shutdown_tx: tokio::sync::watch::channel(false).0, + }; + + let response = handle_health(State(state)).await.into_response(); + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(parsed["status"], "ok"); + assert_eq!(parsed["require_pairing"], true); + } }