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();