feat: bundle qmd binary with app — search works on fresh installs

Replace the fragile auto-install-via-bun approach with a bundled qmd binary.
The build script (scripts/bundle-qmd.sh) compiles qmd into a standalone
binary using `bun build --compile`, then packages it with sqlite-vec native
extensions and a node-llama-cpp stub for keyword-only search.

Key changes:
- find_qmd_binary() now returns QmdBinary with path + work_dir, checks
  bundled resource first (app bundle and dev mode), then system paths
- All Command::new(qmd_path) calls updated to use QmdBinary::command()
  which sets the correct working directory for node_modules resolution
- Removed auto_install_qmd() and find_bun() — no longer needed
- Tauri config bundles resources/qmd/** into the app

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-05 20:44:48 +01:00
parent a3148de1d3
commit 3306d5ff3d
6 changed files with 259 additions and 102 deletions

124
scripts/bundle-qmd.sh Executable file
View File

@@ -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."

View File

@@ -2,3 +2,7 @@
# will have compiled files and executables
/target/
/gen/schemas
# Generated by build scripts
/resources/mcp-server/
/resources/qmd/

View File

@@ -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<Option<String>> = 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<PathBuf>,
}
/// 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<Option<QmdBinary>> = 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<String> {
if let Ok(guard) = QMD_PATH_CACHE.lock() {
pub fn find_qmd_binary() -> Option<QmdBinary> {
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<String> {
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<String> {
fn find_qmd_binary_uncached() -> Option<QmdBinary> {
// 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<String> {
];
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<QmdBinary> {
let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent()?;
// macOS app bundle: <app>/Contents/MacOS/laputa → <app>/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<usize> {
/// 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<F>(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<String, String> {
// 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<String> {
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");

View File

@@ -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| {

View File

@@ -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<SearchResponse, String> {
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,

View File

@@ -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",