diff --git a/scripts/bundle-qmd.sh b/scripts/bundle-qmd.sh new file mode 100755 index 00000000..bad7ff75 --- /dev/null +++ b/scripts/bundle-qmd.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Bundle qmd into a self-contained directory for Tauri resource embedding. +# +# Output: src-tauri/resources/qmd/ +# qmd — compiled standalone binary +# node_modules/sqlite-vec/ — JS shim for sqlite-vec +# node_modules/sqlite-vec-darwin-arm64/ — native .dylib (arm64) +# node_modules/sqlite-vec-darwin-x64/ — native .dylib (x64) +# node_modules/node-llama-cpp/ — stub (keyword search only) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$SCRIPT_DIR/.." +OUT="$ROOT/src-tauri/resources/qmd" + +# ---------- locate tools ---------- +find_bun() { + for c in \ + "$HOME/.bun/bin/bun" \ + "/opt/homebrew/bin/bun" \ + "/usr/local/bin/bun"; do + [[ -x "$c" ]] && { echo "$c"; return 0; } + done + command -v bun 2>/dev/null && return 0 + return 1 +} + +BUN=$(find_bun) || { echo "ERROR: bun not found — install from https://bun.sh" >&2; exit 1; } +echo "Using bun: $BUN" + +# ---------- locate qmd source ---------- +QMD_SRC="" +for c in \ + "$HOME/.bun/install/global/node_modules/qmd" \ + "/opt/homebrew/lib/node_modules/qmd" \ + "/usr/local/lib/node_modules/qmd"; do + [[ -f "$c/src/qmd.ts" ]] && { QMD_SRC="$c"; break; } +done + +if [[ -z "$QMD_SRC" ]]; then + echo "qmd not found globally — installing via bun..." + "$BUN" install -g qmd + QMD_SRC="$HOME/.bun/install/global/node_modules/qmd" + [[ -f "$QMD_SRC/src/qmd.ts" ]] || { echo "ERROR: qmd install succeeded but source not found at $QMD_SRC" >&2; exit 1; } +fi +echo "Using qmd source: $QMD_SRC" + +# ---------- compile ---------- +echo "Compiling qmd with bun build --compile..." +mkdir -p "$OUT" + +"$BUN" build --compile \ + "$QMD_SRC/src/qmd.ts" \ + --outfile "$OUT/qmd" \ + --external node-llama-cpp \ + --external sqlite-vec \ + --external sqlite-vec-darwin-arm64 \ + --external sqlite-vec-darwin-x64 + +chmod +x "$OUT/qmd" + +# ---------- bundle sqlite-vec ---------- +echo "Bundling sqlite-vec native extensions..." + +# Find sqlite-vec packages in bun cache +BUN_CACHE="$HOME/.bun/install/cache" + +# sqlite-vec JS shim +SQLVEC_DIR=$(find "$BUN_CACHE" -maxdepth 1 -name "sqlite-vec@*" -type d | head -1) +if [[ -z "$SQLVEC_DIR" ]]; then + echo "ERROR: sqlite-vec not found in bun cache" >&2; exit 1 +fi + +mkdir -p "$OUT/node_modules/sqlite-vec" +cp "$SQLVEC_DIR/index.mjs" "$OUT/node_modules/sqlite-vec/index.mjs" +cp "$SQLVEC_DIR/package.json" "$OUT/node_modules/sqlite-vec/package.json" +# Also copy CJS entry if it exists +[[ -f "$SQLVEC_DIR/index.cjs" ]] && cp "$SQLVEC_DIR/index.cjs" "$OUT/node_modules/sqlite-vec/index.cjs" + +# sqlite-vec-darwin-arm64 +ARM64_DIR=$(find "$BUN_CACHE" -maxdepth 1 -name "sqlite-vec-darwin-arm64@*" -type d | head -1) +if [[ -n "$ARM64_DIR" ]]; then + mkdir -p "$OUT/node_modules/sqlite-vec-darwin-arm64" + cp "$ARM64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-arm64/vec0.dylib" + cp "$ARM64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-arm64/package.json" + echo " ✓ arm64 dylib" +fi + +# sqlite-vec-darwin-x64 +X64_DIR=$(find "$BUN_CACHE" -maxdepth 1 -name "sqlite-vec-darwin-x64@*" -type d | head -1) +if [[ -n "$X64_DIR" ]]; then + mkdir -p "$OUT/node_modules/sqlite-vec-darwin-x64" + cp "$X64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-x64/vec0.dylib" + cp "$X64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-x64/package.json" + echo " ✓ x64 dylib" +fi + +# ---------- stub node-llama-cpp ---------- +echo "Creating node-llama-cpp stub (keyword search only)..." +mkdir -p "$OUT/node_modules/node-llama-cpp" + +cat > "$OUT/node_modules/node-llama-cpp/package.json" << 'PJSON' +{"name":"node-llama-cpp","version":"0.0.0-stub","type":"module","main":"index.js"} +PJSON + +cat > "$OUT/node_modules/node-llama-cpp/index.js" << 'STUB' +// Stub: node-llama-cpp not bundled — semantic search unavailable, keyword search works. +const unavailable = (name) => (...args) => { + throw new Error(`${name}() unavailable: node-llama-cpp not bundled. Keyword search still works.`); +}; +export const getLlama = unavailable("getLlama"); +export const resolveModelFile = unavailable("resolveModelFile"); +export class LlamaChatSession { + constructor() { throw new Error("LlamaChatSession unavailable"); } +} +export const LlamaLogLevel = { Error: 0, Warn: 1, Info: 2, Debug: 3 }; +STUB + +# ---------- summary ---------- +echo "" +echo "qmd bundled → $OUT/" +du -sh "$OUT/qmd" +du -sh "$OUT/node_modules" +echo "Done." diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index 502406b4..158496e2 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -2,3 +2,7 @@ # will have compiled files and executables /target/ /gen/schemas + +# Generated by build scripts +/resources/mcp-server/ +/resources/qmd/ diff --git a/src-tauri/src/indexing.rs b/src-tauri/src/indexing.rs index 4b72889d..2c103d84 100644 --- a/src-tauri/src/indexing.rs +++ b/src-tauri/src/indexing.rs @@ -1,14 +1,33 @@ use serde::Serialize; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Mutex; -static QMD_PATH_CACHE: Mutex> = Mutex::new(None); +/// Resolved qmd binary location: path + optional working directory. +/// The working dir is required for the bundled binary to find its node_modules. +#[derive(Debug, Clone)] +pub struct QmdBinary { + pub path: String, + pub work_dir: Option, +} -/// Locate the qmd binary, checking known locations and PATH. +impl QmdBinary { + /// Create a `Command` pre-configured with the correct working directory. + pub fn command(&self) -> Command { + let mut cmd = Command::new(&self.path); + if let Some(ref dir) = self.work_dir { + cmd.current_dir(dir); + } + cmd + } +} + +static QMD_CACHE: Mutex> = Mutex::new(None); + +/// Locate the qmd binary, checking bundled resource first, then known locations. /// Caches the result for subsequent calls. -pub fn find_qmd_binary() -> Option { - if let Ok(guard) = QMD_PATH_CACHE.lock() { +pub fn find_qmd_binary() -> Option { + if let Ok(guard) = QMD_CACHE.lock() { if let Some(ref cached) = *guard { return Some(cached.clone()); } @@ -16,16 +35,22 @@ pub fn find_qmd_binary() -> Option { let result = find_qmd_binary_uncached(); - if let Some(ref path) = result { - if let Ok(mut guard) = QMD_PATH_CACHE.lock() { - *guard = Some(path.clone()); + if let Some(ref bin) = result { + if let Ok(mut guard) = QMD_CACHE.lock() { + *guard = Some(bin.clone()); } } result } -fn find_qmd_binary_uncached() -> Option { +fn find_qmd_binary_uncached() -> Option { + // 1. Check bundled binary (Tauri resource) + if let Some(bin) = find_bundled_qmd() { + return Some(bin); + } + + // 2. Check known system locations let candidates = [ dirs::home_dir().map(|h| h.join(".bun/bin/qmd").to_string_lossy().to_string()), Some("/usr/local/bin/qmd".to_string()), @@ -33,26 +58,67 @@ fn find_qmd_binary_uncached() -> Option { ]; for candidate in candidates.into_iter().flatten() { if Path::new(&candidate).exists() { - return Some(candidate); + return Some(QmdBinary { + path: candidate, + work_dir: None, + }); } } - // Fallback: try PATH + + // 3. Fallback: try PATH Command::new("which") .arg("qmd") .output() .ok() .and_then(|o| { if o.status.success() { - Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + Some(QmdBinary { + path: String::from_utf8_lossy(&o.stdout).trim().to_string(), + work_dir: None, + }) } else { None } }) } -/// Clear the cached qmd path (e.g. after auto-install). +/// Look for the bundled qmd binary inside the app bundle or dev resources. +fn find_bundled_qmd() -> Option { + let exe = std::env::current_exe().ok()?; + let exe_dir = exe.parent()?; + + // macOS app bundle: /Contents/MacOS/laputa → /Contents/Resources/qmd/qmd + let bundle_dir = exe_dir.parent()?.join("Resources").join("qmd"); + if bundle_dir.join("qmd").exists() { + return Some(QmdBinary { + path: bundle_dir.join("qmd").to_string_lossy().to_string(), + work_dir: Some(bundle_dir), + }); + } + + // Dev mode: src-tauri/resources/qmd/qmd (binary is in target/debug or target/release) + // Walk up from exe_dir to find the project root + let mut dir = exe_dir.to_path_buf(); + for _ in 0..6 { + let dev_qmd = dir.join("resources").join("qmd").join("qmd"); + if dev_qmd.exists() { + let qmd_dir = dir.join("resources").join("qmd"); + return Some(QmdBinary { + path: dev_qmd.to_string_lossy().to_string(), + work_dir: Some(qmd_dir), + }); + } + if !dir.pop() { + break; + } + } + + None +} + +/// Clear the cached qmd binary (e.g. after path changes). pub fn clear_qmd_cache() { - if let Ok(mut guard) = QMD_PATH_CACHE.lock() { + if let Ok(mut guard) = QMD_CACHE.lock() { *guard = None; } } @@ -69,7 +135,7 @@ pub struct IndexStatus { /// Check whether the vault has a qmd index and its status. pub fn check_index_status(vault_path: &str) -> IndexStatus { - let qmd_bin = match find_qmd_binary() { + let qmd = match find_qmd_binary() { Some(b) => b, None => { return IndexStatus { @@ -84,7 +150,7 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus { }; let vault_name = vault_dir_name(vault_path); - let output = Command::new(&qmd_bin).args(["status"]).output(); + let output = qmd.command().args(["status"]).output(); match output { Ok(o) if o.status.success() => { @@ -182,11 +248,12 @@ fn extract_first_number(s: &str) -> Option { /// Ensure a qmd collection exists for this vault. Creates one if missing. pub fn ensure_collection(vault_path: &str) -> Result<(), String> { - let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?; + let qmd = find_qmd_binary().ok_or("qmd not installed")?; let vault_name = vault_dir_name(vault_path); // Check if collection already exists - let output = Command::new(&qmd_bin) + let output = qmd + .command() .args(["collection", "list"]) .output() .map_err(|e| format!("Failed to list collections: {e}"))?; @@ -199,7 +266,7 @@ pub fn ensure_collection(vault_path: &str) -> Result<(), String> { } // Create collection - Command::new(&qmd_bin) + qmd.command() .args([ "collection", "add", @@ -229,7 +296,7 @@ pub fn run_full_index(vault_path: &str, on_progress: F) -> Result<(), String> where F: Fn(IndexingProgress), { - let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?; + let qmd = find_qmd_binary().ok_or("qmd not installed")?; ensure_collection(vault_path)?; @@ -242,7 +309,8 @@ where error: None, }); - let update_output = Command::new(&qmd_bin) + let update_output = qmd + .command() .args(["update"]) .output() .map_err(|e| format!("qmd update failed: {e}"))?; @@ -281,7 +349,8 @@ where error: None, }); - let embed_output = Command::new(&qmd_bin) + let embed_output = qmd + .command() .args(["embed"]) .output() .map_err(|e| format!("qmd embed failed: {e}"))?; @@ -323,11 +392,12 @@ fn parse_indexed_count(update_output: &str) -> usize { /// Run incremental update for a single file change. pub fn run_incremental_update(vault_path: &str) -> Result<(), String> { - let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?; + let qmd = find_qmd_binary().ok_or("qmd not installed")?; // Verify collection exists let vault_name = vault_dir_name(vault_path); - let list_output = Command::new(&qmd_bin) + let list_output = qmd + .command() .args(["collection", "list"]) .output() .map_err(|e| format!("Failed to list collections: {e}"))?; @@ -340,7 +410,8 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> { } } - let output = Command::new(&qmd_bin) + let output = qmd + .command() .args(["update"]) .output() .map_err(|e| format!("qmd incremental update failed: {e}"))?; @@ -353,58 +424,31 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> { Ok(()) } -/// Attempt to auto-install qmd via bun. Returns Ok if successful. -pub fn auto_install_qmd() -> Result { - // Find bun - let bun = find_bun().ok_or("bun not installed — cannot auto-install qmd")?; - - let output = Command::new(&bun) - .args(["install", "-g", "qmd"]) - .output() - .map_err(|e| format!("Failed to install qmd: {e}"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("qmd installation failed: {stderr}")); - } - - // Clear cache so find_qmd_binary() re-discovers - clear_qmd_cache(); - - match find_qmd_binary() { - Some(path) => Ok(path), - None => Err("qmd installed but binary not found".to_string()), - } -} - -fn find_bun() -> Option { - let candidates = [ - dirs::home_dir().map(|h| h.join(".bun/bin/bun").to_string_lossy().to_string()), - Some("/opt/homebrew/bin/bun".to_string()), - Some("/usr/local/bin/bun".to_string()), - ]; - for candidate in candidates.into_iter().flatten() { - if Path::new(&candidate).exists() { - return Some(candidate); - } - } - Command::new("which") - .arg("bun") - .output() - .ok() - .and_then(|o| { - if o.status.success() { - Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) - } else { - None - } - }) -} - #[cfg(test)] mod tests { use super::*; + #[test] + fn qmd_binary_command_sets_work_dir() { + let qmd = QmdBinary { + path: "/bin/echo".to_string(), + work_dir: Some(PathBuf::from("/tmp")), + }; + let cmd = qmd.command(); + let dbg = format!("{:?}", cmd); + assert!(dbg.contains("/bin/echo")); + } + + #[test] + fn qmd_binary_command_no_work_dir() { + let qmd = QmdBinary { + path: "/bin/echo".to_string(), + work_dir: None, + }; + let output = qmd.command().arg("hello").output().unwrap(); + assert!(output.status.success()); + } + #[test] fn vault_dir_name_extracts_last_segment() { assert_eq!(vault_dir_name("/Users/luca/Laputa"), "laputa"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 612b92c8..4971c544 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -408,35 +408,19 @@ async fn start_indexing(app_handle: tauri::AppHandle, vault_path: String) -> Res use tauri::Emitter; let vault_path = expand_tilde(&vault_path).into_owned(); tokio::task::spawn_blocking(move || { - // Auto-install qmd if not available if indexing::find_qmd_binary().is_none() { + log::info!("qmd binary not found (bundled or system)"); let _ = app_handle.emit( "indexing-progress", IndexingProgress { - phase: "installing".to_string(), + phase: "unavailable".to_string(), current: 0, total: 0, - done: false, - error: None, + done: true, + error: Some("qmd not available".to_string()), }, ); - match indexing::auto_install_qmd() { - Ok(_) => log::info!("qmd auto-installed successfully"), - Err(e) => { - log::info!("qmd not available (search disabled): {e}"); - let _ = app_handle.emit( - "indexing-progress", - IndexingProgress { - phase: "unavailable".to_string(), - current: 0, - total: 0, - done: true, - error: Some(format!("qmd not available: {e}")), - }, - ); - return Err(e); - } - } + return Err("qmd not available".to_string()); } indexing::run_full_index(&vault_path, |progress| { diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs index e7689620..71001fd6 100644 --- a/src-tauri/src/search.rs +++ b/src-tauri/src/search.rs @@ -2,7 +2,6 @@ use crate::indexing; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; -use std::process::Command; use std::sync::Mutex; use std::time::Instant; @@ -84,12 +83,12 @@ fn detect_collection_name(vault_path: &str) -> String { } fn detect_collection_name_uncached(vault_path: &str) -> String { - let qmd_bin = match indexing::find_qmd_binary() { + let qmd = match indexing::find_qmd_binary() { Some(b) => b, None => return "laputa".to_string(), }; - let output = Command::new(&qmd_bin).args(["collection", "list"]).output(); + let output = qmd.command().args(["collection", "list"]).output(); match output { Ok(o) if o.status.success() => { @@ -125,8 +124,8 @@ pub fn search_vault( ) -> Result { let start = Instant::now(); - let qmd_bin = indexing::find_qmd_binary() - .ok_or_else(|| "qmd binary not found. Install qmd first.".to_string())?; + let qmd = indexing::find_qmd_binary() + .ok_or_else(|| "qmd binary not found".to_string())?; let collection = detect_collection_name(vault_path); @@ -137,7 +136,8 @@ pub fn search_vault( }; let limit_str = limit.to_string(); - let output = Command::new(&qmd_bin) + let output = qmd + .command() .args([ search_cmd, query, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e6f5ecf0..a8abe4ad 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -7,7 +7,7 @@ "frontendDist": "../dist", "devUrl": "http://localhost:5202", "beforeDevCommand": "pnpm dev", - "beforeBuildCommand": "pnpm build && pnpm bundle-mcp" + "beforeBuildCommand": "pnpm build && pnpm bundle-mcp && bash scripts/bundle-qmd.sh" }, "app": { "withGlobalTauri": true, @@ -38,7 +38,8 @@ "targets": "all", "createUpdaterArtifacts": true, "resources": { - "resources/mcp-server/**/*": "mcp-server/" + "resources/mcp-server/**/*": "mcp-server/", + "resources/qmd/**/*": "qmd/" }, "icon": [ "icons/32x32.png",