refactor: replace bash wrapper with standard #!/usr/bin/env node shebang

The qmd bin was a custom bash script that discovered node via hardcoded
fallback paths (mise, asdf, nvm, homebrew). This was nonstandard and
caused ABI mismatches when installed via bun (native modules compiled
for bun but executed with node).

Now uses the standard npm bin convention: dist/qmd.js with a node
shebang, added by the build script. The isMain guard resolves symlinks
so it works when npm/bun create symlinked bin entries.

Also converts all dynamic require() calls in tests to ESM imports, and
adds container-based smoke tests (test/smoke-install.sh) that verify
install + run under both node and bun via mise in a Debian container.
This commit is contained in:
Tobi Lutke 2026-02-20 21:52:53 -04:00
parent a815b52ede
commit 0b57711d32
No known key found for this signature in database
10 changed files with 138 additions and 70 deletions

View File

@ -4,17 +4,16 @@
"description": "Query Markup Documents - On-device hybrid search for markdown files with BM25, vector search, and LLM reranking",
"type": "module",
"bin": {
"qmd": "qmd"
"qmd": "dist/qmd.js"
},
"files": [
"dist/",
"qmd",
"LICENSE",
"CHANGELOG.md"
],
"scripts": {
"prepare": "[ -d .git ] && ./scripts/install-hooks.sh || true",
"build": "tsc -p tsconfig.build.json",
"build": "tsc -p tsconfig.build.json && printf '#!/usr/bin/env node\n' | cat - dist/qmd.js > dist/qmd.tmp && mv dist/qmd.tmp dist/qmd.js && chmod +x dist/qmd.js",
"test": "vitest run --reporter=verbose test/",
"qmd": "tsx src/qmd.ts",
"index": "tsx src/qmd.ts index",

46
qmd
View File

@ -1,46 +0,0 @@
#!/usr/bin/env bash
# qmd - Quick Markdown Search
set -euo pipefail
# Find node - prefer PATH, fallback to known locations
find_node() {
if command -v node &>/dev/null; then
local ver=$(node --version 2>/dev/null | sed 's/^v//' || echo "0")
local major="${ver%%.*}"
if [[ "$major" -ge 22 ]]; then
command -v node
return 0
fi
fi
# Fallback: derive paths (need HOME)
: "${HOME:=$(eval echo ~)}"
# Check known locations
local candidates=(
"$HOME/.local/share/mise/installs/node/latest/bin/node"
"$HOME/.local/share/mise/shims/node"
"$HOME/.asdf/shims/node"
"/opt/homebrew/bin/node"
"/usr/local/bin/node"
"$HOME/.nvm/current/bin/node"
)
for c in "${candidates[@]}"; do
[[ -x "$c" ]] && { echo "$c"; return 0; }
done
return 1
}
NODE=$(find_node) || { echo "Error: node (>=22) not found. Install from https://nodejs.org" >&2; exit 1; }
# Resolve symlinks to find script location
SOURCE="${BASH_SOURCE[0]}"
while [[ -L "$SOURCE" ]]; do
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
SOURCE="$(readlink "$SOURCE")"
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
done
SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
exec "$NODE" "$SCRIPT_DIR/dist/qmd.js" "$@"

View File

@ -5,7 +5,7 @@ import { execSync, spawn as nodeSpawn } from "child_process";
import { fileURLToPath } from "url";
import { dirname, join as pathJoin } from "path";
import { parseArgs } from "util";
import { readFileSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync } from "fs";
import { readFileSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync } from "fs";
import {
getPwd,
getRealPath,
@ -2384,7 +2384,13 @@ async function showVersion(): Promise<void> {
}
// Main CLI - only run if this is the main module
if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/qmd.ts") || process.argv[1]?.endsWith("/qmd.js")) {
const __filename = fileURLToPath(import.meta.url);
const argv1 = process.argv[1];
const isMain = argv1 === __filename
|| argv1?.endsWith("/qmd.ts")
|| argv1?.endsWith("/qmd.js")
|| (argv1 != null && realpathSync(argv1) === __filename);
if (isMain) {
const cli = parseCLI();
if (cli.values.version) {

21
test/Containerfile Normal file
View File

@ -0,0 +1,21 @@
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl ca-certificates bash git build-essential python3 libatomic1 && \
rm -rf /var/lib/apt/lists/*
# Install mise
ENV MISE_YES=1
RUN curl https://mise.run | sh
ENV PATH="/root/.local/bin:$PATH"
# Pre-install node and bun
RUN mise use -g node@latest bun@latest
# Copy the packed tarball and test script
COPY tobilu-qmd-*.tgz /tmp/
COPY smoke-install-test.sh /tmp/
RUN chmod +x /tmp/smoke-install-test.sh
CMD ["/tmp/smoke-install-test.sh"]

View File

@ -7,7 +7,7 @@
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";
import { existsSync } from "fs";
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
@ -1068,7 +1068,6 @@ describe("mcp http daemon", () => {
}
// Also clean up via PID file if present
try {
const { readFileSync, existsSync, unlinkSync } = require("fs");
const pf = pidPath();
if (existsSync(pf)) {
const pid = parseInt(readFileSync(pf, "utf-8").trim());
@ -1115,7 +1114,6 @@ describe("mcp http daemon", () => {
expect(stdout).toContain(`http://localhost:${port}/mcp`);
// PID file should exist
const { existsSync, readFileSync } = require("fs");
expect(existsSync(pidPath())).toBe(true);
const pid = parseInt(readFileSync(pidPath(), "utf-8").trim());
@ -1128,7 +1126,7 @@ describe("mcp http daemon", () => {
// Clean up
process.kill(pid, "SIGTERM");
await sleep(500);
try { require("fs").unlinkSync(pidPath()); } catch {}
try { unlinkSync(pidPath()); } catch {}
});
test("stop kills daemon and removes PID file", async () => {
@ -1139,7 +1137,6 @@ describe("mcp http daemon", () => {
]);
expect(startCode).toBe(0);
const { readFileSync } = require("fs");
const pid = parseInt(readFileSync(pidPath(), "utf-8").trim());
spawnedPids.push(pid);
@ -1151,7 +1148,7 @@ describe("mcp http daemon", () => {
expect(stopOut).toContain("Stopped");
// PID file should be gone
expect(require("fs").existsSync(pidPath())).toBe(false);
expect(existsSync(pidPath())).toBe(false);
// Process should be dead
await sleep(500);
@ -1160,7 +1157,6 @@ describe("mcp http daemon", () => {
test("stop handles dead PID gracefully (cleans stale file)", async () => {
// Write a PID file pointing to a dead process
const { writeFileSync } = require("fs");
writeFileSync(pidPath(), "999999999");
const { stdout, exitCode } = await runDaemonQmd(["mcp", "stop"]);
@ -1168,7 +1164,7 @@ describe("mcp http daemon", () => {
expect(stdout).toContain("stale");
// PID file should be cleaned up
expect(require("fs").existsSync(pidPath())).toBe(false);
expect(existsSync(pidPath())).toBe(false);
});
test("--daemon rejects if already running", async () => {
@ -1179,7 +1175,6 @@ describe("mcp http daemon", () => {
]);
expect(firstCode).toBe(0);
const { readFileSync } = require("fs");
const pid = parseInt(readFileSync(pidPath(), "utf-8").trim());
spawnedPids.push(pid);
@ -1195,12 +1190,11 @@ describe("mcp http daemon", () => {
// Clean up first daemon
process.kill(pid, "SIGTERM");
await sleep(500);
try { require("fs").unlinkSync(pidPath()); } catch {}
try { unlinkSync(pidPath()); } catch {}
});
test("--daemon cleans stale PID file and starts fresh", async () => {
// Write a stale PID file
const { writeFileSync, readFileSync } = require("fs");
writeFileSync(pidPath(), "999999999");
const port = randomPort();
@ -1219,6 +1213,6 @@ describe("mcp http daemon", () => {
expect(ready).toBe(true);
process.kill(pid, "SIGTERM");
await sleep(500);
try { require("fs").unlinkSync(pidPath()); } catch {}
try { unlinkSync(pidPath()); } catch {}
});
});

View File

@ -11,6 +11,7 @@ import type { Database } from "../src/db.js";
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { getDefaultLlamaCpp, disposeDefaultLlamaCpp } from "../src/llm";
import { unlinkSync } from "node:fs";
import { mkdtemp, writeFile, readdir, unlink, rmdir } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
@ -238,7 +239,7 @@ describe("MCP Server", () => {
afterAll(async () => {
testDb.close();
try {
require("fs").unlinkSync(testDbPath);
unlinkSync(testDbPath);
} catch {}
// Clean up test config directory
@ -920,7 +921,7 @@ describe("MCP HTTP Transport", () => {
else delete process.env.QMD_CONFIG_DIR;
// Clean up test files
try { require("fs").unlinkSync(httpTestDbPath); } catch {}
try { unlinkSync(httpTestDbPath); } catch {}
try {
const files = await readdir(httpTestConfigDir);
for (const f of files) await unlink(join(httpTestConfigDir, f));

View File

@ -6,6 +6,7 @@
*/
import { describe, test, expect } from "vitest";
import { parseArgs } from "node:util";
// Reproduce the filterByCollections logic from qmd.ts for testing
// (the function is private in qmd.ts)
@ -108,7 +109,6 @@ describe("resolveCollectionFilter input normalization", () => {
describe("collection option type from parseArgs", () => {
// Verify that parseArgs with `multiple: true` produces string[]
test("parseArgs multiple:true produces array for repeated flags", () => {
const { parseArgs } = require("node:util");
const { values } = parseArgs({
args: ["-c", "docs", "-c", "notes"],
options: {
@ -120,7 +120,6 @@ describe("collection option type from parseArgs", () => {
});
test("parseArgs multiple:true produces array for single flag", () => {
const { parseArgs } = require("node:util");
const { values } = parseArgs({
args: ["-c", "docs"],
options: {
@ -132,7 +131,6 @@ describe("collection option type from parseArgs", () => {
});
test("parseArgs multiple:true produces undefined when flag absent", () => {
const { parseArgs } = require("node:util");
const { values } = parseArgs({
args: [],
options: {

58
test/smoke-install-test.sh Executable file
View File

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Smoke test: install @tobilu/qmd from tarball and verify it runs under node and bun.
# Both runtimes need node on PATH (the bin uses #!/usr/bin/env node shebang).
set -uo pipefail
TARBALL=$(ls /tmp/tobilu-qmd-*.tgz | head -1)
PASS=0
FAIL=0
TMP=$(mktemp)
ok() { printf " %-44s OK\n" "$1"; PASS=$((PASS + 1)); }
fail() { printf " %-44s FAIL\n" "$1"; FAIL=$((FAIL + 1)); cat "$TMP" | sed 's/^/ /'; }
NODE_BIN="$(mise where node@latest)/bin"
BUN_BIN="$(mise where bun@latest)/bin"
BASE_PATH="/root/.local/bin:/usr/local/bin:/usr/bin:/bin"
# ---------------------------------------------------------------------------
# Node: install via npm, runs with node (via shebang)
# ---------------------------------------------------------------------------
echo "=== Node $($NODE_BIN/node --version) ==="
export PATH="$NODE_BIN:$BASE_PATH"
if npm install -g "$TARBALL" >"$TMP" 2>&1; then ok "npm install -g"
else fail "npm install -g"; fi
timeout 10 qmd >"$TMP" 2>&1 || true
if grep -q "Usage:" "$TMP"; then ok "qmd shows help"
else fail "qmd shows help"; fi
if timeout 10 qmd collection list >"$TMP" 2>&1; then ok "qmd collection list"
else fail "qmd collection list"; fi
# ---------------------------------------------------------------------------
# Bun: install via bun, still runs with node (shebang)
# ---------------------------------------------------------------------------
echo ""
echo "=== Bun $($BUN_BIN/bun --version) ==="
export PATH="$BUN_BIN:$HOME/.bun/bin:$NODE_BIN:$BASE_PATH"
if bun install -g "$TARBALL" >"$TMP" 2>&1; then ok "bun install -g"
else fail "bun install -g"; fi
timeout 10 "$HOME/.bun/bin/qmd" >"$TMP" 2>&1 || true
if grep -q "Usage:" "$TMP"; then ok "qmd shows help (bun-installed)"
else fail "qmd shows help (bun-installed)"; fi
if timeout 10 "$HOME/.bun/bin/qmd" collection list >"$TMP" 2>&1; then ok "qmd collection list (bun-installed)"
else fail "qmd collection list (bun-installed)"; fi
rm -f "$TMP"
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
[[ $FAIL -eq 0 ]]

38
test/smoke-install.sh Executable file
View File

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Build, pack, and smoke-test qmd in a container with mise + node + bun.
# Works with docker or podman (whichever is available).
set -euo pipefail
cd "$(dirname "$0")/.."
# Pick container runtime
if command -v podman &>/dev/null; then
CTR=podman
elif command -v docker &>/dev/null; then
CTR=docker
else
echo "Error: neither podman nor docker found" >&2
exit 1
fi
echo "Using: $CTR"
# Build TypeScript
echo "==> Building TypeScript..."
npm run build --silent
# Pack tarball into test/ (the build context)
echo "==> Packing tarball..."
rm -f test/tobilu-qmd-*.tgz
TARBALL=$(npm pack --pack-destination test/ 2>/dev/null | tail -1)
echo " $TARBALL"
# Build container image
echo "==> Building container..."
$CTR build -f test/Containerfile -t qmd-smoke test/
# Run smoke tests
echo "==> Running smoke tests..."
$CTR run --rm qmd-smoke
# Clean up tarball
rm -f test/tobilu-qmd-*.tgz

View File

@ -16,6 +16,7 @@ import { join } from "node:path";
import {
createStore,
structuredSearch,
validateSemanticQuery,
type StructuredSubSearch,
type Store,
} from "../src/store.js";
@ -327,8 +328,6 @@ describe("lex query syntax", () => {
// Note: These test via CLI behavior since buildFTS5Query is not exported
describe("validateSemanticQuery", () => {
// Import the validation function
const { validateSemanticQuery } = require("../src/store.js");
test("accepts plain natural language", () => {
expect(validateSemanticQuery("how does error handling work")).toBeNull();