From 0b57711d3244845bba75b91a4d84a3fad9ebe21e Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Fri, 20 Feb 2026 21:52:53 -0400 Subject: [PATCH] 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. --- package.json | 5 +-- qmd | 46 ---------------------- src/qmd.ts | 10 ++++- test/Containerfile | 21 ++++++++++ test/cli.test.ts | 18 +++------ test/mcp.test.ts | 5 ++- test/multi-collection-filter.test.ts | 4 +- test/smoke-install-test.sh | 58 ++++++++++++++++++++++++++++ test/smoke-install.sh | 38 ++++++++++++++++++ test/structured-search.test.ts | 3 +- 10 files changed, 138 insertions(+), 70 deletions(-) delete mode 100755 qmd create mode 100644 test/Containerfile create mode 100755 test/smoke-install-test.sh create mode 100755 test/smoke-install.sh diff --git a/package.json b/package.json index 3d8adb7..de136e3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/qmd b/qmd deleted file mode 100755 index 19489c9..0000000 --- a/qmd +++ /dev/null @@ -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" "$@" diff --git a/src/qmd.ts b/src/qmd.ts index d57b7e8..3d3a716 100755 --- a/src/qmd.ts +++ b/src/qmd.ts @@ -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 { } // 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) { diff --git a/test/Containerfile b/test/Containerfile new file mode 100644 index 0000000..ac1b48f --- /dev/null +++ b/test/Containerfile @@ -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"] diff --git a/test/cli.test.ts b/test/cli.test.ts index b723e7d..8c96107 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -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 {} }); }); diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 24cf528..67e6e1e 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -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)); diff --git a/test/multi-collection-filter.test.ts b/test/multi-collection-filter.test.ts index 31500b1..a71cbff 100644 --- a/test/multi-collection-filter.test.ts +++ b/test/multi-collection-filter.test.ts @@ -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: { diff --git a/test/smoke-install-test.sh b/test/smoke-install-test.sh new file mode 100755 index 0000000..f7e7ac6 --- /dev/null +++ b/test/smoke-install-test.sh @@ -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 ]] diff --git a/test/smoke-install.sh b/test/smoke-install.sh new file mode 100755 index 0000000..82f31a6 --- /dev/null +++ b/test/smoke-install.sh @@ -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 diff --git a/test/structured-search.test.ts b/test/structured-search.test.ts index 0a4c8c6..b466ebc 100644 --- a/test/structured-search.test.ts +++ b/test/structured-search.test.ts @@ -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();