Эх сурвалжийг харах

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.
Tobi Lutke 3 сар өмнө
parent
commit
0b57711d32

+ 2 - 3
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",

+ 0 - 46
qmd

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

+ 8 - 2
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<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 - 0
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"]

+ 6 - 12
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 {}
   });
 });

+ 3 - 2
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));

+ 1 - 3
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: {

+ 58 - 0
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 ]]

+ 38 - 0
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

+ 1 - 2
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();